diff --git a/inc/ui/class-checkout-element.php b/inc/ui/class-checkout-element.php index e15c42752..af5f8a475 100644 --- a/inc/ui/class-checkout-element.php +++ b/inc/ui/class-checkout-element.php @@ -81,6 +81,31 @@ class Checkout_Element extends Base_Element { */ protected $public = true; + /** + * Tracks if no-cache checkout signals were already sent for this request. + * + * @since 2.4.14 + * @var bool + */ + protected $checkout_nocache_sent = false; + + /** + * Initializes hooks specific to the checkout element. + * + * @since 2.4.14 + * @return void + */ + public function init() { + + parent::init(); + + add_action('wu_ajax_wu_render_checkout', [$this, 'render_deferred_checkout']); + + add_action('wu_ajax_nopriv_wu_render_checkout', [$this, 'render_deferred_checkout']); + + add_filter('wu_light_ajax_should_skip_referer_check', [$this, 'allow_deferred_checkout_ajax_without_referer']); + } + /** * The icon of the UI element. * e.g. return fa fa-search @@ -161,6 +186,23 @@ public function fields() { 'type' => 'text', ]; + $fields['defer'] = [ + 'title' => __('Defer Checkout Loading', 'ultimate-multisite'), + 'desc' => __('Outputs a cache-safe placeholder first, then loads fresh checkout markup after visitor intent.', 'ultimate-multisite'), + 'type' => 'toggle', + ]; + + $fields['defer_trigger'] = [ + 'title' => __('Deferred Loading Trigger', 'ultimate-multisite'), + 'desc' => __('Choose when the deferred checkout placeholder should request the live checkout form.', 'ultimate-multisite'), + 'type' => 'select', + 'options' => [ + 'viewport' => __('When the placeholder enters the viewport', 'ultimate-multisite'), + 'click' => __('Only after a click or focus', 'ultimate-multisite'), + 'load' => __('As soon as the page loads', 'ultimate-multisite'), + ], + ]; + return $fields; } @@ -213,6 +255,8 @@ public function defaults() { 'step' => false, 'display_title' => false, 'membership_limitations' => [], + 'defer' => false, + 'defer_trigger' => 'viewport', ]; } @@ -241,9 +285,65 @@ public function setup() { return; } + $pre_loaded_attributes = is_array($this->pre_loaded_attributes) ? $this->pre_loaded_attributes : []; + + $atts = wp_parse_args($pre_loaded_attributes, $this->defaults()); + + if ($this->should_defer_checkout($atts)) { + return; + } + + $this->send_checkout_nocache_headers($atts); + do_action('wu_setup_checkout', $this); } + /** + * Skips the checkout script enqueue pass for cache-safe deferred placeholders. + * + * @since 2.4.14 + * @return void + */ + public function enqueue_element_scripts() { + + global $post; + + if ( ! is_a($post, '\WP_Post')) { + return; + } + + if ($this->contains_current_element($post->post_content, $post)) { + $pre_loaded_attributes = is_array($this->pre_loaded_attributes) ? $this->pre_loaded_attributes : []; + + $atts = wp_parse_args($pre_loaded_attributes, $this->defaults()); + + if ($this->should_defer_checkout($atts)) { + return; + } + } + + parent::enqueue_element_scripts(); + } + + /** + * Allows the deferred checkout light-AJAX request to work from cached pages. + * + * Cached placeholders can outlive nonce windows, so the endpoint intentionally + * avoids relying on the cacheable `r` nonce. The endpoint only renders the same + * public checkout form that the shortcode/block would render directly. + * + * @since 2.4.14 + * + * @param array $allowed_actions Light-AJAX actions allowed without referer checks. + * @return array + */ + public function allow_deferred_checkout_ajax_without_referer($allowed_actions) { + + $allowed_actions[] = 'wu_render_checkout'; + + return array_unique($allowed_actions); + } + /** * @return void */ @@ -275,6 +375,507 @@ public function register_scripts() { } } + /** + * Renders the live checkout markup for deferred placeholders. + * + * @since 2.4.14 + * @return void + */ + public function render_deferred_checkout() { + + $atts = $this->get_deferred_checkout_request_atts(); + + $_REQUEST['checkout_form'] = $atts['slug']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $this->pre_loaded_attributes = $atts; + + $this->set_checkout_intent_cookie(); + + $this->send_checkout_nocache_headers($atts); + + $this->setup(); + + do_action('wu_checkout_scripts', null, $this); + + ob_start(); + + $this->output($atts); + + $html = ob_get_clean(); + + ob_start(); + + wp_print_styles(['wu-checkout', 'wu-admin', 'wu-password']); + + wp_print_scripts(['wu-checkout']); + + $assets = ob_get_clean(); + + wp_send_json_success( + [ + 'html' => $assets . $html, + ] + ); + } + + /** + * Builds safe checkout attributes from the deferred checkout request. + * + * @since 2.4.14 + * @return array + */ + protected function get_deferred_checkout_request_atts() { + + $membership_limitations = wu_request('membership_limitations', []); + + if (is_string($membership_limitations)) { + $membership_limitations = explode(',', $membership_limitations); + } + + if ( ! is_array($membership_limitations)) { + $membership_limitations = []; + } + + $membership_limitations = array_values(array_filter(array_map('sanitize_key', $membership_limitations))); + + return wp_parse_args( + [ + 'slug' => sanitize_text_field((string) wu_request('slug', 'main-form')), + 'step' => sanitize_key((string) wu_request('step', '')) ?: false, + 'display_title' => $this->is_truthy_attribute(wu_request('display_title', false)), + 'membership_limitations' => $membership_limitations, + 'defer' => false, + ], + $this->defaults() + ); + } + + /** + * Sets a lightweight intent cookie that cache layers can use as a bypass signal. + * + * @since 2.4.14 + * @return void + */ + protected function set_checkout_intent_cookie() { + + $cookie = new \Delight\Cookie\Cookie('wu_checkout_intent'); + $cookie->setValue('1'); + $cookie->setMaxAge(HOUR_IN_SECONDS); + $cookie->setPath('/'); + $cookie->setDomain(COOKIE_DOMAIN); + $cookie->setHttpOnly(true); + $cookie->setSecureOnly(is_ssl()); + $cookie->setSameSiteRestriction('Lax'); + $cookie->save(); + + $_COOKIE['wu_checkout_intent'] = '1'; + } + + /** + * Sends no-cache signals for live checkout markup. + * + * @since 2.4.14 + * + * @param array $atts Checkout block/shortcode attributes. + * @return void + */ + protected function send_checkout_nocache_headers($atts) { + + if ($this->checkout_nocache_sent) { + return; + } + + $this->checkout_nocache_sent = true; + + if ( ! defined('DONOTCACHEPAGE')) { + define('DONOTCACHEPAGE', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound + } + + /** + * Fires when live checkout output requires a fresh, non-cacheable response. + * + * Cache adapters can use this action to integrate with host-specific bypass + * APIs that do not rely on standard WordPress constants or HTTP headers. + * + * @since 2.4.14 + * + * @param array $atts Checkout block/shortcode attributes. + * @param Checkout_Element $element Checkout element instance. + */ + do_action('wu_checkout_nocache_required', $atts, $this); + + do_action('litespeed_control_set_nocache', 'Ultimate Multisite checkout contains per-request state.'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + + if (headers_sent()) { + return; + } + + nocache_headers(); + + $headers = [ + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0, private', + 'Pragma' => 'no-cache', + 'Expires' => 'Wed, 11 Jan 1984 05:00:00 GMT', + 'Surrogate-Control' => 'no-store', + 'CDN-Cache-Control' => 'no-store', + 'X-Accel-Expires' => '0', + 'X-LiteSpeed-Cache-Control' => 'no-cache', + ]; + + /** + * Filters additional no-cache headers sent with live checkout markup. + * + * @since 2.4.14 + * + * @param array $headers Header name/value pairs. + * @param array $atts Checkout block/shortcode attributes. + * @param Checkout_Element $element Checkout element instance. + */ + $headers = apply_filters('wu_checkout_nocache_headers', $headers, $atts, $this); + + foreach ($headers as $name => $value) { + $value = str_replace(["\r", "\n"], '', (string) $value); + + header(sprintf('%s: %s', sanitize_key($name), $value), true); + } + } + + /** + * Checks if a checkout attribute value should be treated as true. + * + * @since 2.4.14 + * + * @param mixed $value Attribute value. + * @return bool + */ + protected function is_truthy_attribute($value) { + + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + return (bool) $value; + } + + /** + * Checks if the current request already expresses live checkout intent. + * + * @since 2.4.14 + * @return bool + */ + protected function has_checkout_intent() { + + if (isset($_COOKIE['wu_checkout_intent']) || isset($_COOKIE['wu_session_signup'])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + return true; + } + + $live_request_keys = apply_filters( + 'wu_checkout_live_request_keys', + [ + 'checkout_action', + 'checkout_form', + 'payment', + 'payment_id', + 'pre-flight', + 'resume_checkout', + 'step', + 'wu_form', + ], + $this + ); + + foreach ($live_request_keys as $request_key) { + if (wu_request($request_key)) { + return true; + } + } + + return false; + } + + /** + * Checks if checkout output should be deferred for cache safety. + * + * @since 2.4.14 + * + * @param array $atts Checkout block/shortcode attributes. + * @return bool + */ + protected function should_defer_checkout($atts) { + + if (wu_is_update_page() || wu_is_new_site_page() || $this->is_thank_you_page()) { + return false; + } + + $should_defer = $this->is_truthy_attribute($atts['defer'] ?? false) && ! $this->has_checkout_intent(); + + return apply_filters('wu_checkout_should_defer_output', $should_defer, $atts, $this); + } + + /** + * Returns a supported deferred checkout trigger. + * + * @since 2.4.14 + * + * @param array $atts Checkout block/shortcode attributes. + * @return string + */ + protected function get_deferred_checkout_trigger($atts) { + + $trigger = sanitize_key((string) wu_get_isset($atts, 'defer_trigger', 'viewport')); + + return in_array($trigger, ['click', 'load', 'viewport'], true) ? $trigger : 'viewport'; + } + + /** + * Builds the payload used by the deferred checkout request. + * + * @since 2.4.14 + * + * @param array $atts Checkout block/shortcode attributes. + * @return array + */ + protected function get_deferred_checkout_payload($atts) { + + $membership_limitations = wu_get_isset($atts, 'membership_limitations', []); + + if ( ! is_array($membership_limitations)) { + $membership_limitations = []; + } + + return [ + 'action' => 'wu_render_checkout', + 'slug' => sanitize_text_field((string) wu_get_isset($atts, 'slug', 'main-form')), + 'step' => sanitize_key((string) wu_get_isset($atts, 'step', '')), + 'display_title' => $this->is_truthy_attribute(wu_get_isset($atts, 'display_title', false)) ? 1 : 0, + 'membership_limitations' => array_values(array_filter(array_map('sanitize_key', $membership_limitations))), + ]; + } + + /** + * Outputs a cache-safe placeholder that can request live checkout markup later. + * + * @since 2.4.14 + * + * @param array $atts Checkout block/shortcode attributes. + * @return void + */ + protected function output_deferred_placeholder($atts) { + + $placeholder_id = wp_unique_id('wu-checkout-deferred-'); + $endpoint = wu_ajax_url('init', ['action' => 'wu_render_checkout']); + $payload = $this->get_deferred_checkout_payload($atts); + $trigger = $this->get_deferred_checkout_trigger($atts); + + ?> +