215 lines
7.2 KiB
PHP
215 lines
7.2 KiB
PHP
<?php
|
|
/**
|
|
* Class AsyncPriceRenderer
|
|
*
|
|
* @package WooCommerce\Payments\MultiCurrency
|
|
*/
|
|
|
|
namespace WCPay\MultiCurrency;
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* Renders skeleton price markup for cache-optimized mode.
|
|
*
|
|
* When cache mode is active and no WC session exists, this class replaces
|
|
* server-side price conversion with skeleton placeholders that are converted
|
|
* by JavaScript on the client side.
|
|
*/
|
|
class AsyncPriceRenderer {
|
|
|
|
/**
|
|
* MultiCurrency instance.
|
|
*
|
|
* @var MultiCurrency
|
|
*/
|
|
private $multi_currency;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param MultiCurrency $multi_currency The MultiCurrency instance.
|
|
*/
|
|
public function __construct( MultiCurrency $multi_currency ) {
|
|
$this->multi_currency = $multi_currency;
|
|
}
|
|
|
|
/**
|
|
* Initializes hooks for async price rendering.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function init_hooks() {
|
|
// is_cache_optimized_mode() checks both the feature flag and the rendering mode option.
|
|
if ( ! $this->multi_currency->is_cache_optimized_mode() ) {
|
|
return;
|
|
}
|
|
|
|
if ( is_admin() || defined( 'DOING_CRON' ) || Utils::is_admin_api_request() ) {
|
|
return;
|
|
}
|
|
|
|
// If there's an active session, let FrontendPrices handle it.
|
|
if ( $this->multi_currency->has_active_session() ) {
|
|
return;
|
|
}
|
|
|
|
add_filter( 'wc_price', [ $this, 'wrap_price_with_skeleton' ], 999, 5 );
|
|
add_filter( 'woocommerce_format_sale_price', [ $this, 'annotate_sale_price_sr_text' ], 999, 3 );
|
|
add_filter( 'woocommerce_format_price_range', [ $this, 'annotate_price_range_sr_text' ], 999, 3 );
|
|
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_async_renderer' ] );
|
|
}
|
|
|
|
/**
|
|
* Wraps a price with skeleton markup for client-side conversion.
|
|
*
|
|
* @param string $return The formatted price string.
|
|
* @param float $price The raw price.
|
|
* @param array $args Arguments passed to wc_price.
|
|
* @param float $unformatted_price The unformatted price.
|
|
* @param float $original_price The original price before any conversion.
|
|
*
|
|
* @return string The wrapped price markup.
|
|
*/
|
|
public function wrap_price_with_skeleton( $return, $price, $args, $unformatted_price, $original_price ) {
|
|
// The async renderer only runs on non-session pages (catalog/product).
|
|
// Cart/checkout have active sessions and use server-side FrontendPrices.
|
|
// Default to 'product' since catalog pages only call wc_price for products.
|
|
$price_type = apply_filters( 'wcpay_multi_currency_async_price_type', 'product', $price, $args );
|
|
|
|
// We use $unformatted_price (the raw float before number_format) rather
|
|
// than $price (which is locale-formatted, e.g. "20,00" in European
|
|
// locales). The JS async renderer parses this with Decimal.js which
|
|
// expects dot-decimal notation.
|
|
//
|
|
// The screen-reader-text placeholder contains the original WC-formatted
|
|
// price so crawlers and screen readers on slow connections see a real
|
|
// price before JS loads. JS removes it after successful conversion.
|
|
//
|
|
// The wrapper reuses the woocommerce-Price-amount/amount classes so the
|
|
// DOM hierarchy matches what wc_price() normally produces. This avoids
|
|
// an extra nesting level that could break theme CSS selectors like
|
|
// `.price > .woocommerce-Price-amount`. JS replaces the <bdi> contents
|
|
// in-place rather than appending a new child element.
|
|
return sprintf(
|
|
'<span class="woocommerce-Price-amount amount wcpay-async-price" data-wcpay-price="%s" data-wcpay-price-type="%s"><bdi class="wcpay-price-skeleton"></bdi><span class="screen-reader-text wcpay-price-placeholder">%s</span></span>',
|
|
esc_attr( $unformatted_price ),
|
|
esc_attr( $price_type ),
|
|
wp_kses_post( $return )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enqueues the async price renderer script and styles.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function enqueue_async_renderer() {
|
|
$this->multi_currency->register_script_with_dependencies(
|
|
'wcpay-multi-currency-async-renderer',
|
|
'dist/multi-currency-async-renderer'
|
|
);
|
|
|
|
wp_localize_script(
|
|
'wcpay-multi-currency-async-renderer',
|
|
'wcpayAsyncPriceConfig',
|
|
[
|
|
'apiUrl' => rest_url( 'wc/v3/payments/multi-currency/public/config' ),
|
|
'defaultCurrency' => [
|
|
'symbol' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
|
|
'decimals' => wc_get_price_decimals(),
|
|
'decimal_sep' => wc_get_price_decimal_separator(),
|
|
'thousand_sep' => wc_get_price_thousand_separator(),
|
|
'symbol_pos' => get_option( 'woocommerce_currency_pos' ),
|
|
],
|
|
// Uses WC's text domain so translations match WC core output in every locale.
|
|
// phpcs:disable WordPress.WP.I18n.TextDomainMismatch
|
|
'srText' => [
|
|
/* translators: %s: formatted price */
|
|
'sale_original' => __( 'Original price was: %s.', 'woocommerce' ),
|
|
/* translators: %s: formatted price */
|
|
'sale_current' => __( 'Current price is: %s.', 'woocommerce' ),
|
|
/* translators: %1$s: minimum price, %2$s: maximum price */
|
|
'range' => __( 'Price range: %1$s through %2$s', 'woocommerce' ),
|
|
],
|
|
// phpcs:enable WordPress.WP.I18n.TextDomainMismatch
|
|
]
|
|
);
|
|
|
|
wp_enqueue_script( 'wcpay-multi-currency-async-renderer' );
|
|
|
|
wp_enqueue_style(
|
|
'wcpay-multi-currency-async-renderer',
|
|
plugins_url(
|
|
'dist/multi-currency-async-renderer.css',
|
|
WCPAY_PLUGIN_FILE
|
|
),
|
|
[],
|
|
$this->multi_currency->get_file_version( 'dist/multi-currency-async-renderer.css' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Annotates screen-reader-text spans in sale price HTML with data attributes
|
|
* so the client-side renderer can rebuild them in the target currency.
|
|
*
|
|
* @param string $price_html The formatted sale price HTML.
|
|
* @param string $regular_price The regular price.
|
|
* @param string $sale_price The sale price.
|
|
*
|
|
* @return string The annotated HTML.
|
|
*/
|
|
public function annotate_sale_price_sr_text( $price_html, $regular_price, $sale_price ) {
|
|
if ( ! is_numeric( $regular_price ) || ! is_numeric( $sale_price ) ) {
|
|
return $price_html;
|
|
}
|
|
|
|
$count = 0;
|
|
return preg_replace_callback(
|
|
'/<span class="screen-reader-text">/',
|
|
function () use ( $regular_price, $sale_price, &$count ) {
|
|
$count++;
|
|
if ( 1 === $count ) {
|
|
return sprintf(
|
|
'<span class="screen-reader-text" data-wcpay-sr-type="sale_original" data-wcpay-sr-price="%s">',
|
|
esc_attr( $regular_price )
|
|
);
|
|
}
|
|
return sprintf(
|
|
'<span class="screen-reader-text" data-wcpay-sr-type="sale_current" data-wcpay-sr-price="%s">',
|
|
esc_attr( $sale_price )
|
|
);
|
|
},
|
|
$price_html,
|
|
2
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Annotates the screen-reader-text span in price range HTML with data attributes
|
|
* so the client-side renderer can rebuild it in the target currency.
|
|
*
|
|
* @param string $price_html The formatted price range HTML.
|
|
* @param string $from The "from" price.
|
|
* @param string $to The "to" price.
|
|
*
|
|
* @return string The annotated HTML.
|
|
*/
|
|
public function annotate_price_range_sr_text( $price_html, $from, $to ) {
|
|
if ( ! is_numeric( $from ) || ! is_numeric( $to ) ) {
|
|
return $price_html;
|
|
}
|
|
|
|
return preg_replace(
|
|
'/<span class="screen-reader-text">/',
|
|
sprintf(
|
|
'<span class="screen-reader-text" data-wcpay-sr-type="range" data-wcpay-sr-price-from="%s" data-wcpay-sr-price-to="%s">',
|
|
esc_attr( $from ),
|
|
esc_attr( $to )
|
|
),
|
|
$price_html,
|
|
1
|
|
);
|
|
}
|
|
}
|