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

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

649 lines 25.4 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\Core\ServiceContainerTrait;
4 use TierPricingTable\PriceManager;
5 use TierPricingTable\PricingRule;
6 use TierPricingTable\PricingTable;
7 use TierPricingTable\Settings\Settings;
8 use TierPricingTable\TierPricingTablePlugin;
9 use WC_Product;
10
11 /**
12 * Live preview of the pricing layout for the settings page.
13 *
14 * Renders the real frontend templates with sample tiers on an in-memory preview product, using the values currently
15 * chosen in the form (layout, design style, colour, title, quantity format, ...). The markup is shown
16 * inside an iframe so the frontend stylesheet and the templates' own form controls stay isolated from
17 * the admin page. The tooltip layout is not previewed.
18 */
19 class LayoutPreview {
20
21 use ServiceContainerTrait;
22
23 const AJAX_ACTION = 'tpt_layout_preview';
24
25 /**
26 * Preview kinds: what the panel demonstrates and which layout it forces.
27 */
28 const KINDS = array(
29 'display_type' => null,
30 'table_style' => 'table',
31 'blocks_style' => 'blocks',
32 'options_style' => 'options',
33 );
34
35 /**
36 * Form fields (option ids without prefix) the preview reacts to, mapped to renderer settings.
37 * "bool" fields arrive as yes/no.
38 */
39 const FIELDS = array(
40 'display_type' => array( 'display_type', 'text' ),
41 'pricing_table_style' => array( 'table_style', 'text' ),
42 'pricing_blocks_style' => array( 'blocks_style', 'text' ),
43 'pricing_options_style' => array( 'options_style', 'text' ),
44 'pricing_dropdown_style' => array( 'dropdown_style', 'text' ),
45 'pricing_plain_text_style' => array( 'plain_text_style', 'text' ),
46 'quantity_type' => array( 'quantity_type', 'text' ),
47 'tiers_order' => array( 'tiers_order', 'text' ),
48 'discount_format' => array( 'discount_format', 'text' ),
49 'table_title' => array( 'title', 'text' ),
50 'selected_quantity_color' => array( 'active_tier_color', 'color' ),
51 'compact_layout' => array( 'compact_layout', 'yesno' ),
52 'layout_spacing' => array( 'layout_spacing', 'text' ),
53 'cell_padding' => array( 'cell_padding', 'text' ),
54 'discount_badge_color' => array( 'discount_badge_color', 'optional-color' ),
55 'table_active_border' => array( 'active_tier_border', 'yesno' ),
56 'tiers_limit' => array( 'tiers_limit', 'text' ),
57 'head_quantity_text' => array( 'quantity_column_title', 'text' ),
58 'head_price_text' => array( 'price_column_title', 'text' ),
59 'head_discount_text' => array( 'discount_column_title', 'text' ),
60 'show_discount_column' => array( 'show_discount_column', 'bool' ),
61 'clickable_table_rows' => array( 'clickable_rows', 'bool' ),
62 'options_show_total' => array( 'options_show_total', 'bool' ),
63 'options_show_original_product_price' => array( 'options_show_original_product_price', 'bool' ),
64 'options_show_default_option' => array( 'options_show_default_option', 'bool' ),
65 'options_default_option_text' => array( 'options_default_option_text', 'html' ),
66 'options_option_text' => array( 'options_option_text', 'html' ),
67 'tooltip_border' => array( 'tooltip_border', 'bool' ),
68 'plain_text_template' => array( 'plain_text_option_text', 'html' ),
69 'plain_text_show_first_tier' => array( 'plain_text_show_default_option', 'bool' ),
70 'plain_text_first_tier_template' => array( 'plain_text_default_option_text', 'html' ),
71 );
72
73 const CONTEXTS = array( 'product-page', 'shop-loop' );
74
75 /**
76 * Regular price of the preview product (store currency). The skeleton shows the same amount.
77 */
78 const PRICE = 45;
79
80 protected ?WC_Product $product = null;
81
82 protected bool $productResolved = false;
83
84 public function __construct() {
85 add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'ajax' ) );
86 }
87
88 /**
89 * Field names the JS collects from the form.
90 */
91 public static function getFieldNames(): array {
92 return array_map( function ( $id ) {
93 return Settings::SETTINGS_PREFIX . $id;
94 }, array_keys( self::FIELDS ) );
95 }
96
97 public function getFrontendStylesheetURL(): string {
98 return $this->getContainer()->getFileManager()->locateCSSAsset( 'frontend/main.css' );
99 }
100
101 /**
102 * Stylesheets for the preview document: the active theme's style.css (parent first when a child
103 * theme is active), then the plugin's frontend stylesheet.
104 */
105 /**
106 * The theme's global styles (theme.json: colours, fonts, element styles) and its font faces, as WordPress
107 * prints them inline on the storefront. Empty for a theme without them.
108 */
109 public function getInlineStyles(): string {
110 $css = '';
111
112 if ( function_exists( 'wp_print_font_faces' ) ) {
113 ob_start();
114 wp_print_font_faces();
115 $css .= preg_replace( '#</?style[^>]*>#i', '', (string) ob_get_clean() );
116 }
117
118 if ( function_exists( 'wp_get_global_stylesheet' ) ) {
119 $css .= "\n" . wp_get_global_stylesheet();
120 }
121
122 // the CSS is inlined into the preview document
123 return str_replace( '</style', '<\/style', trim( $css ) );
124 }
125
126 public function getStylesheetURLs(): array {
127 $urls = array();
128 $theme = wp_get_theme();
129
130 if ( $theme->exists() ) {
131 if ( $theme->parent() ) {
132 $urls[] = add_query_arg( 'ver', $theme->parent()->get( 'Version' ), get_template_directory_uri() . '/style.css' );
133 }
134 $urls[] = add_query_arg( 'ver', $theme->get( 'Version' ), get_stylesheet_uri() );
135 }
136
137 // WooCommerce's storefront stylesheets, in the storefront's order: the block theme sheet comes first,
138 // the plugin's sheet next and WooCommerce's general sheets last (a theme that ships its own WooCommerce
139 // styles drops them through WooCommerce's filter)
140 $woocommerce = function_exists( 'WC' ) && class_exists( 'WC_Frontend_Scripts' );
141 $version = defined( 'WC_VERSION' ) ? WC_VERSION : '';
142
143 if ( $woocommerce && function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
144 $urls[] = add_query_arg( 'ver', $version, WC()->plugin_url() . '/assets/css/woocommerce-blocktheme.css' );
145 }
146
147 // the plugin's sheet is versioned with its modification time as well: the plugin version alone lets a
148 // browser keep a cached copy while the templates move on (development, git deployments)
149 $stylesheet = $this->getFrontendStylesheetURL();
150 $file = dirname( __DIR__, 3 ) . '/assets/frontend/' . basename( (string) wp_parse_url( $stylesheet, PHP_URL_PATH ) );
151 $urls[] = add_query_arg( 'ver', TierPricingTablePlugin::VERSION . ( file_exists( $file ) ? '.' . filemtime( $file ) : '' ), $stylesheet );
152
153 if ( $woocommerce ) {
154 foreach ( \WC_Frontend_Scripts::get_styles() as $handle => $style ) {
155 // the small screen sheet is bound to a media query the preview frame does not honour
156 if ( 'woocommerce-smallscreen' === $handle || empty( $style['src'] ) ) {
157 continue;
158 }
159 $urls[] = add_query_arg( 'ver', $style['version'] ?? $version, $style['src'] );
160 }
161 }
162
163 return array_values( array_unique( $urls ) );
164 }
165
166 /**
167 * Full document for the preview iframe. The wrapper mirrors a product page (body classes, div.product
168 * and .summary) so theme rules written for WooCommerce product pages apply.
169 */
170 public function getDocument( string $fragment ): string {
171 $links = '';
172 foreach ( $this->getStylesheetURLs() as $url ) {
173 $links .= '<link rel="stylesheet" href="' . esc_url( $url ) . '">';
174 }
175
176 return '<!doctype html><html><head><meta charset="utf-8">' . $links
177 . '<style>html,body{margin:0;background:#fff}body{padding:16px}.tpt-preview-note{margin:0;color:#646970}</style></head>'
178 . '<body class="woocommerce woocommerce-page single-product"><div class="woocommerce"><div class="product"><div class="summary entry-summary">'
179 . $fragment . '</div></div></div></body></html>';
180 }
181
182 /**
183 * Values currently stored, in the form the AJAX request uses (id without prefix => value).
184 */
185 public function getStoredValues(): array {
186 $values = array();
187 foreach ( self::FIELDS as $id => $map ) {
188 $values[ $id ] = $this->getContainer()->getSettings()->get( $id );
189 }
190
191 return array_merge( $values, LayoutConfigurator::getUnitValues() );
192 }
193
194 /**
195 * Render the preview fragment for a panel.
196 *
197 * @param string $kind One of self::KINDS.
198 * @param array $values Form values (id without prefix => value).
199 */
200 public function render( string $kind, array $values, string $context = 'product-page' ): string {
201 if ( ! array_key_exists( $kind, self::KINDS ) ) {
202 return '';
203 }
204
205 $settings = 'shop-loop' === $context ? $this->mapCatalogSettings( $values ) : $this->mapSettings( $values );
206
207 if ( self::KINDS[ $kind ] ) {
208 $settings['display_type'] = self::KINDS[ $kind ];
209 }
210
211 if ( 'tooltip' === $settings['display_type'] ) {
212 $note = 'shop-loop' === $context
213 ? esc_html__( 'The tooltip layout is not available on shop and category pages. Choose "Custom" and pick another layout.', 'tier-pricing-table' )
214 : esc_html__( 'The tooltip layout shows the pricing table next to the product price when the customer hovers over an icon. Open a product page to see it in action.', 'tier-pricing-table' );
215
216 return '<p class="tpt-preview-note">' . $note . '</p>';
217 }
218
219 $product = $this->getProduct();
220
221 $settings['display_context'] = $context;
222 $settings['display'] = true;
223
224 ob_start();
225 try {
226 PricingTable::getInstance()->renderPricingTableHTML( $product, $product, $settings );
227 } catch ( \Throwable $e ) {
228 ob_end_clean();
229
230 return '<p class="tpt-preview-note">' . esc_html__( 'The preview could not be rendered.', 'tier-pricing-table' ) . '</p>';
231 }
232
233 $html = ob_get_clean();
234
235 // the product page skeleton shows the first discounted tier selected; highlight that tier too
236 if ( 'product-page' === $context ) {
237 $html = $this->highlightTier( $html, (int) array_key_first( self::SAMPLE_TIERS ) );
238 }
239
240 return '<div class="tpt__tiered-pricing" data-display-type="' . esc_attr( $settings['display_type'] ) . '">' . $html . '</div>';
241 }
242
243 /**
244 * Move the "active tier" markers, which the frontend script moves on quantity change, to the tier
245 * element of the given quantity. Only class attributes are touched, so the templates' inline CSS
246 * for the active state keeps working.
247 */
248 protected function highlightTier( string $html, int $quantity ): string {
249 $markers = array( 'tiered-pricing--active', 'tiered-pricing-option-checkbox--active', 'tiered-pricing-dropdown-option--selected' );
250
251 $html = preg_replace_callback( '/\bclass="([^"]*)"/', function ( $m ) use ( $markers ) {
252 $classes = array_diff( preg_split( '/\s+/', trim( $m[1] ) ), $markers );
253
254 return 'class="' . implode( ' ', $classes ) . '"';
255 }, $html );
256 $html = str_replace( 'aria-selected="true"', 'aria-selected="false"', $html );
257
258 $activated = false;
259 $html = preg_replace_callback( '/<(tr|div|li)\b[^>]*\bdata-tiered-quantity="' . $quantity . '"[^>]*>/', function ( $m ) use ( &$activated ) {
260 $tag = $m[0];
261 if ( $activated ) {
262 return $tag;
263 }
264 $activated = true;
265 $add = 'tiered-pricing--active' . ( false !== strpos( $tag, 'tiered-pricing-dropdown-option' ) ? ' tiered-pricing-dropdown-option--selected' : '' );
266 $tag = preg_match( '/\bclass="/', $tag )
267 ? preg_replace( '/\bclass="/', 'class="' . $add . ' ', $tag, 1 )
268 : preg_replace( '/^<(\w+)/', '<$1 class="' . $add . '"', $tag, 1 );
269
270 return str_replace( 'aria-selected="false"', 'aria-selected="true"', $tag );
271 }, $html );
272
273 // the options layouts mark the active option's checkbox as well
274 $active = strpos( $html, 'class="tiered-pricing--active' );
275 if ( false !== $active ) {
276 $needle = 'class="tiered-pricing-option-checkbox"';
277 $checkbox = strpos( $html, $needle, $active );
278 if ( false !== $checkbox ) {
279 $html = substr_replace( $html, 'class="tiered-pricing-option-checkbox tiered-pricing-option-checkbox--active"', $checkbox, strlen( $needle ) );
280 }
281 }
282
283 return $html;
284 }
285
286 public function getProductName(): string {
287 return $this->getProduct()->get_name();
288 }
289
290 public function ajax() {
291 check_ajax_referer( self::AJAX_ACTION, 'nonce' );
292
293 if ( ! current_user_can( 'manage_woocommerce' ) ) {
294 wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
295 }
296
297 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; every value is sanitized by its field type below
298 $rawValues = isset( $_POST['values'] ) && is_array( $_POST['values'] ) ? wp_unslash( $_POST['values'] ) : array();
299 // phpcs:ignore WordPress.Security.NonceVerification.Missing
300 $kinds = isset( $_POST['kinds'] ) && is_array( $_POST['kinds'] ) ? array_map( 'sanitize_key', wp_unslash( $_POST['kinds'] ) ) : array();
301
302 // phpcs:ignore WordPress.Security.NonceVerification.Missing
303 $context = isset( $_POST['context'] ) ? sanitize_key( wp_unslash( $_POST['context'] ) ) : 'product-page';
304 $context = in_array( $context, self::CONTEXTS, true ) ? $context : 'product-page';
305
306 $values = array();
307 foreach ( $this->getScalarFieldTypes() as $id => $type ) {
308 if ( array_key_exists( $id, $rawValues ) && is_scalar( $rawValues[ $id ] ) ) {
309 $raw = (string) $rawValues[ $id ];
310 switch ( $type ) {
311 case 'color':
312 case 'optional-color':
313 $values[ $id ] = sanitize_hex_color( $raw );
314 break;
315 case 'html':
316 $values[ $id ] = wp_kses_post( $raw );
317 break;
318 default:
319 $values[ $id ] = sanitize_text_field( $raw );
320 }
321 }
322 }
323 // only known layouts, styles and formats reach the renderer; anything else falls back to the stored value
324 foreach ( LayoutConfigurator::getChoiceLists() as $id => $allowed ) {
325 if ( isset( $values[ $id ] ) && ! in_array( $values[ $id ], $allowed, true ) ) {
326 unset( $values[ $id ] );
327 }
328 }
329
330 $unitIds = array_merge( array_keys( LayoutConfigurator::UNIT_OPTIONS ), array_values( LayoutConfigurator::CATALOG_UNIT_OPTIONS ) );
331 foreach ( $unitIds as $id ) {
332 if ( isset( $rawValues[ $id ] ) && is_array( $rawValues[ $id ] ) ) {
333 $values[ $id ] = array(
334 'singular' => sanitize_text_field( (string) ( $rawValues[ $id ]['singular'] ?? '' ) ),
335 'plural' => sanitize_text_field( (string) ( $rawValues[ $id ]['plural'] ?? '' ) ),
336 );
337 }
338 }
339
340 $documents = array();
341 $fragments = array();
342 foreach ( $kinds as $kind ) {
343 if ( array_key_exists( $kind, self::KINDS ) ) {
344 $fragments[ $kind ] = $this->render( $kind, $values, $context );
345 $documents[ $kind ] = $this->getDocument( $fragments[ $kind ] );
346 }
347 }
348
349 wp_send_json_success( array(
350 'documents' => $documents,
351 'fragments' => $fragments,
352 'extras' => array(
353 'catalogPrice' => $this->renderCatalogPrice( $values ),
354 'summary' => $this->renderSummary( $values ),
355 ),
356 'stylesheets' => $this->getStylesheetURLs(),
357 'product' => $this->getProductName(),
358 ) );
359 }
360
361 /**
362 * Scalar fields the request may carry (option id => sanitiser type): the product page fields and their
363 * catalog counterparts, plus the catalog's "layout source" choice.
364 */
365 protected function getScalarFieldTypes(): array {
366 $types = array();
367 foreach ( self::FIELDS as $id => $map ) {
368 $types[ $id ] = $map[1];
369 }
370 foreach ( LayoutConfigurator::CATALOG_OPTIONS as $key => $id ) {
371 if ( isset( self::FIELDS[ $key ] ) ) {
372 $types[ $id ] = self::FIELDS[ $key ][1];
373 }
374 }
375 $types[ LayoutConfigurator::CATALOG_OPTIONS['layout_settings'] ] = 'text';
376 // price line options that decide which tier the product page preview highlights
377 $types['product_page_price_format'] = 'text';
378 $types['update_price_on_product_page'] = 'text';
379 // pricing summary block and shop price format (rendered as extras)
380 foreach ( array_keys( LayoutConfigurator::SUMMARY_OPTIONS ) as $id ) {
381 $types[ $id ] = 'text';
382 }
383 foreach ( array_keys( LayoutConfigurator::CATALOG_PRICE_OPTIONS ) as $id ) {
384 $types[ $id ] = 'tiered_price_at_catalog_custom_template' === $id ? 'html' : 'text';
385 }
386
387 return $types;
388 }
389
390 /**
391 * Run a callback while some options read as the given (unsaved) form values, so code that reads
392 * the settings directly renders the preview state.
393 */
394 protected function withOptions( array $overrides, callable $callback ) {
395 $filters = array();
396 foreach ( $overrides as $id => $value ) {
397 $hook = 'pre_option_' . Settings::SETTINGS_PREFIX . $id;
398 $filters[ $hook ] = function () use ( $value ) {
399 return $value;
400 };
401 add_filter( $hook, $filters[ $hook ] );
402 }
403 try {
404 return $callback();
405 } finally {
406 foreach ( $filters as $hook => $filter ) {
407 remove_filter( $hook, $filter );
408 }
409 }
410 }
411
412 protected function plainText( $html ): string {
413 $html = preg_replace( '#<span class="screen-reader-text">.*?</span>#s', '', (string) $html );
414
415 return trim( html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' ) );
416 }
417
418 /**
419 * The preview product's price as the shop page formats it with the form's catalog price options:
420 * the lowest tier price with its prefix, the range, or the custom template. Computed from the sample
421 * amounts (the real formatter is premium code), so the free version previews it too. HTML.
422 */
423 public function renderCatalogPrice( array $values ): string {
424 $get = function ( $id, $default ) use ( $values ) {
425 return array_key_exists( $id, $values ) ? $values[ $id ] : $this->getContainer()->getSettings()->get( $id, $default );
426 };
427
428 $lowest = round( self::PRICE * ( 1 - max( self::SAMPLE_TIERS ) ), 2 );
429 $lowest = esc_html( $this->plainText( wc_price( $lowest ) ) );
430 $regular = esc_html( $this->plainText( wc_price( self::PRICE ) ) );
431 $range = $lowest . ' - ' . $regular;
432
433 switch ( (string) $get( 'tiered_price_at_catalog_type', 'lowest' ) ) {
434 case 'range':
435 return $range;
436 case 'custom':
437 $template = (string) $get( 'tiered_price_at_catalog_custom_template', __( 'From {tp_lowest_price} instead of {tp_original_price}', 'tier-pricing-table' ) );
438
439 return strtr( wp_kses_post( $template ), array(
440 '{tp_lowest_price}' => $lowest,
441 '{tp_prices_range}' => $range,
442 '{tp_original_price}' => $regular,
443 ) );
444 default:
445 return trim( esc_html( (string) $get( 'lowest_prefix', __( 'From', 'tier-pricing-table' ) ) ) . ' ' . $lowest );
446 }
447 }
448
449 /**
450 * The pricing summary block for the preview product with the form's options, its figures filled in
451 * for the sample quantity (the frontend script fills them on the real page). Rendered whatever the
452 * licence, so the free version previews the premium block.
453 */
454 public function renderSummary( array $values ): string {
455 if ( ! LayoutConfigurator::isSummaryAvailable() ) {
456 return '';
457 }
458
459 $get = function ( $id, $default ) use ( $values ) {
460 return array_key_exists( $id, $values ) ? $values[ $id ] : $this->getContainer()->getSettings()->get( $id, $default );
461 };
462
463 $type = (string) $get( 'summary_type', 'table' );
464 $type = in_array( $type, array( 'detailed', 'table', 'inline' ), true ) ? $type : 'table';
465
466 ob_start();
467 try {
468 $this->getContainer()->getFileManager()->includeTemplate( 'frontend/summary-' . $type . '.php', array(
469 'productId' => $this->getProduct()->get_id(),
470 'needHide' => false,
471 'totalLabel' => (string) $get( 'summary_total_label', __( 'Total:', 'tier-pricing-table' ) ),
472 'eachLabel' => (string) $get( 'summary_each_label', __( 'Each: ', 'tier-pricing-table' ) ),
473 'title' => (string) $get( 'summary_title', '' ),
474 'showNonTiered' => 'yes' === $get( 'display_summary_non_tiered', 'no' ) ? 'yes' : 'no',
475 ) );
476 } catch ( \Throwable $e ) {
477 ob_end_clean();
478
479 return '';
480 }
481 $html = ob_get_clean();
482
483 /** @var LayoutConfigurator $configurator */
484 $configurator = $this->getContainer()->get( 'settings.layout_configurator' );
485 $samples = $configurator->getSamples();
486 $figures = array(
487 'product-qty' => $samples['quantity'],
488 'product-name' => $samples['productName'],
489 'product-old-price' => $samples['price'],
490 'product-price' => $samples['tierPrice'],
491 'total-with-tax' => $samples['tierTotal'],
492 'total' => $samples['tierTotal'],
493 );
494 foreach ( $figures as $key => $text ) {
495 $html = preg_replace_callback(
496 '/(data-tier-pricing-table-summary-' . preg_quote( $key, '/' ) . '(?:="[^"]*")?(?:\s[^>]*)?>)\s*(<\/(?:span|div)>)/',
497 function ( $m ) use ( $text ) {
498 return $m[1] . esc_html( (string) $text ) . $m[2]; // a callback: prices contain "$"
499 },
500 $html
501 );
502 }
503
504 return str_replace( ' tier-pricing-summary-table--hidden', '', $html );
505 }
506
507 /**
508 * Settings for the shop & category preview: the product page settings when the catalog follows the
509 * product page, otherwise the catalog's own values translated to the product page keys.
510 */
511 protected function mapCatalogSettings( array $values ): array {
512 $sourceKey = LayoutConfigurator::CATALOG_OPTIONS['layout_settings'];
513 $custom = isset( $values[ $sourceKey ] ) ? 'custom' === $values[ $sourceKey ] : \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::isCustomLayoutSettings();
514
515 if ( ! $custom ) {
516 // catalog-only options that apply whatever the layout source
517 $translated = $values;
518 foreach ( array( 'tiers_limit' ) as $key ) {
519 $id = LayoutConfigurator::CATALOG_OPTIONS[ $key ];
520 if ( array_key_exists( $id, $values ) ) {
521 $translated[ $key ] = $values[ $id ];
522 }
523 }
524
525 return $this->mapSettings( $translated );
526 }
527
528 $translated = array();
529 foreach ( array_merge( LayoutConfigurator::CATALOG_OPTIONS, LayoutConfigurator::CATALOG_UNIT_OPTIONS ) as $key => $id ) {
530 if ( array_key_exists( $id, $values ) ) {
531 $translated[ $key ] = $values[ $id ];
532 }
533 }
534 // a style withheld from shop pages renders as the default there
535 foreach ( array( 'table' => 'pricing_table_style', 'blocks' => 'pricing_blocks_style', 'options' => 'pricing_options_style', 'dropdown' => 'pricing_dropdown_style', 'plain-text' => 'pricing_plain_text_style' ) as $layout => $key ) {
536 if ( isset( $translated[ $key ] ) ) {
537 $translated[ $key ] = LayoutConfigurator::catalogStyle( $layout, (string) $translated[ $key ] );
538 }
539 }
540
541 return $this->mapSettings( $translated );
542 }
543
544 /**
545 * Map form values onto the renderer's settings keys. Missing values fall back to the stored ones
546 * through the renderer's defaults.
547 */
548 protected function mapSettings( array $values ): array {
549 $settings = array();
550
551 foreach ( self::FIELDS as $id => $map ) {
552 if ( ! array_key_exists( $id, $values ) || null === $values[ $id ] ) {
553 continue;
554 }
555 list( $key, $type ) = $map;
556 $value = $values[ $id ];
557
558 switch ( $type ) {
559 case 'bool':
560 $settings[ $key ] = 'yes' === $value || true === $value;
561 break;
562 case 'color':
563 $settings[ $key ] = $value ? $value : '#3858e9';
564 break;
565 case 'optional-color':
566 $settings[ $key ] = (string) $value;
567 break;
568 default:
569 $settings[ $key ] = (string) $value;
570 }
571 }
572
573 if ( empty( $settings['display_type'] ) ) {
574 $settings['display_type'] = $this->getContainer()->getSettings()->get( 'display_type', 'table' );
575 }
576
577 // quantity unit of the layout being rendered (the renderer derives it from the stored layout)
578 foreach ( LayoutConfigurator::UNIT_OPTIONS as $id => $layouts ) {
579 if ( in_array( $settings['display_type'], $layouts, true ) && isset( $values[ $id ] ) && is_array( $values[ $id ] ) ) {
580 $settings['quantity_measurement_singular'] = (string) ( $values[ $id ]['singular'] ?? '' );
581 $settings['quantity_measurement_plural'] = (string) ( $values[ $id ]['plural'] ?? '' );
582 }
583 }
584
585 return $settings;
586 }
587
588 /**
589 * Sample tiers as quantity => discount share of the preview price.
590 */
591 const SAMPLE_TIERS = array(
592 10 => 0.10,
593 25 => 0.20,
594 50 => 0.30,
595 );
596
597 /**
598 * The preview product: an in-memory product with a fixed price and the sample tiers, so the preview
599 * never depends on the store's catalogue. WooCommerce's factory is taught to return it for its id,
600 * because the renderer loads products by id along the way.
601 */
602 public function getProduct(): WC_Product {
603 if ( $this->productResolved ) {
604 return $this->product;
605 }
606 $this->productResolved = true;
607
608 $productId = PreviewProduct::ID;
609
610 add_filter( 'woocommerce_product_type_query', function ( $type, $id ) use ( $productId ) {
611 return (int) $id === $productId ? 'simple' : $type;
612 }, 10, 2 );
613
614 add_filter( 'woocommerce_product_class', function ( $classname, $type, $postType, $id ) use ( $productId ) {
615 return (int) $id === $productId ? PreviewProduct::class : $classname;
616 }, 10, 4 );
617
618 $sampleRules = array();
619 foreach ( self::SAMPLE_TIERS as $quantity => $discount ) {
620 $sampleRules[ $quantity ] = round( self::PRICE * ( 1 - $discount ), 2 );
621 }
622
623 add_filter( 'tiered_pricing_table/price/pricing_rule', function ( $pricingRule, $id ) use ( $productId, $sampleRules ) {
624 if ( (int) $id === $productId && $pricingRule instanceof PricingRule ) {
625 $pricingRule->setType( 'fixed' );
626 $pricingRule->setRules( $sampleRules );
627 $pricingRule->setMinimum( null );
628 }
629
630 return $pricingRule;
631 }, 999, 2 );
632
633 // Forget any rule cached for this id earlier in the request so the sample tiers apply.
634 try {
635 $cache = new \ReflectionProperty( PriceManager::class, 'pricingRules' );
636 $cache->setAccessible( true );
637 $rules = (array) $cache->getValue();
638 unset( $rules[ $productId ] );
639 $cache->setValue( null, $rules );
640 } catch ( \ReflectionException $e ) {
641 // Cache layout changed: the sample tiers still apply on first computation.
642 }
643
644 $this->product = new PreviewProduct();
645
646 return $this->product;
647 }
648 }
649