Repo:
awesomemotive/merchant-proType: Enhancement / Compatibility Labels: enhancement, compatibility Author: DevWael
The Cart Reserved Timer module is incompatible with the WooCommerce Cart Block (woocommerce/cart). The module exclusively relies on the classic hook woocommerce_before_cart to inject its countdown timer HTML, which does not fire when the Cart Block renders the page.
Additionally, the module uses a GET-parameter-based cart clearing mechanism (?merchant-wc-clear-cart) that performs a server-side WC()->cart->empty_cart(). This approach is problematic with the Block Cart because:
- The block cart uses the WooCommerce Store API for state mutations, not page reloads.
- Clearing the cart server-side and redirecting breaks the React-based cart's expected state flow.
File: Merchant-Pro/inc/modules/cart-reserved-timer/class-cart-reserved-timer.php
Line 55:
add_action( 'woocommerce_before_cart', array( $this, 'add_template_to_cart' ) );The woocommerce_before_cart hook is a classic shortcode-era hook. It fires inside the [woocommerce_cart] shortcode output. The Cart Block (<woocommerce/cart>) renders entirely via React + the Store API (/wp-json/wc/store/v1/cart) and never invokes classic cart hooks.
File: Merchant/inc/modules/cart-reserved-timer/admin/options.php
Lines 14-25:
if ( defined( 'MERCHANT_PRO_VERSION' ) && merchant_is_cart_block_layout() ) {
$before_fields = array(
'type' => 'warning',
'content' => sprintf(
__( 'Your cart page is being rendered through the new WooCommerce cart block...' ),
'https://docs.athemes.com/article/...'
),
);
}This tells users to revert to the classic shortcode — a friction point WooCommerce is actively moving away from.
File: Merchant-Pro/inc/modules/cart-reserved-timer/class-cart-reserved-timer.php
The enqueue_styles() (line 83) and enqueue_scripts() (line 97) methods do not check merchant_is_cart_block_layout(). Compare with how Free Shipping Progress Bar guards its classic hooks:
File: Merchant-Pro/inc/modules/free-shipping-progress-bar/class-free-shipping-progress-bar.php
Line 931:
if ( ! $force && ( ( merchant_is_cart_block_layout() && is_cart() ) || ... ) ) {
return; // Skip classic rendering on block layouts
}The cart reserved timer loads its classic JS/CSS unconditionally, which is wasted bandwidth on block-based cart pages where the template never renders.
Two modules already implement Block Cart compatibility using the same architectural pattern:
| Component | File |
|---|---|
| Blocks Integration PHP | Merchant-Pro/inc/modules/free-shipping-progress-bar/class-free-shipping-progress-bar-blocks-integration.php |
| Blocks JS | Merchant-Pro/assets/js/src/modules/free-shipping-progress-bar/free-shipping-progress-bar-blocks.js |
| Classic guard | class-free-shipping-progress-bar.php:931 |
Architecture:
- Registers Store API endpoint data via
woocommerce_store_api_register_endpoint_data()onwoocommerce_blocks_loaded data_callbackrenders the PHP template viaob_start()/ob_get_clean()and returns HTML + metadata- A dedicated React-compatible JS file subscribes to
wc/store/cart, readscart.extensions[namespace], and injects the HTML into the DOM - Classic hooks are guarded with
merchant_is_cart_block_layout()checks
| Component | File |
|---|---|
| Blocks Integration PHP | Merchant-Pro/inc/modules/cart-checkout-offers/class-cart-checkout-offers-blocks-integration.php |
Same pattern but more complex (per-item offers, multiple placements). Overkill for a reference here.
Create: Merchant-Pro/inc/modules/cart-reserved-timer/class-cart-reserved-timer-blocks-integration.php
<?php
/**
* Cart Reserved Timer - Blocks Integration
*
* @package Merchant_Pro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Merchant_Pro_Cart_Reserved_Timer_Blocks_Integration {
private const MODULE_ID = 'cart-reserved-timer';
private const NAMESPACE = 'merchant/v1/cart-reserved-timer';
private static $instance = null;
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
public function load_hooks() {
add_action( 'woocommerce_blocks_loaded', array( $this, 'register_store_api_data' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_block_assets' ) );
}
public function register_store_api_data() {
if ( ! $this->is_module_active() ) {
return;
}
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) {
return;
}
woocommerce_store_api_register_endpoint_data( array(
'endpoint' => 'cart',
'namespace' => self::NAMESPACE,
'data_callback' => array( $this, 'extend_cart_data' ),
'schema_callback' => array( $this, 'extend_cart_schema' ),
'schema_type' => ARRAY_A,
) );
}
public function extend_cart_data( $cart_data = null ) {
$module = Merchant_Modules::get_module( Merchant_Cart_Reserved_Timer::MODULE_ID );
if ( ! $module ) {
return array( 'is_visible' => false, 'html' => '', 'settings' => array() );
}
$settings = $module->get_settings();
$cart_empty = WC()->cart && WC()->cart->get_cart_contents_count() === 0;
if ( $cart_empty ) {
return array( 'is_visible' => false, 'html' => '', 'settings' => array() );
}
ob_start();
$args = array_merge( $settings, array( 'css' => 'display: none' ) );
merchant_get_template_part( Merchant_Cart_Reserved_Timer::MODULE_TEMPLATES, 'cart', $args );
$html = ob_get_clean();
return array(
'is_visible' => true,
'html' => $html,
'settings' => array(
'duration' => $settings['duration'] ?? 10,
'time_expires' => $settings['time_expires'] ?? 'clear-cart',
),
);
}
public function extend_cart_schema() {
return array(
'is_visible' => array(
'description' => __( 'Whether the cart reserved timer should be visible.', 'merchant-pro' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'html' => array(
'description' => __( 'Rendered HTML of the cart reserved timer.', 'merchant-pro' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'settings' => array(
'description' => __( 'Timer settings for client-side countdown.', 'merchant-pro' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => array(
'duration' => array( 'type' => 'number' ),
'time_expires' => array( 'type' => 'string' ),
),
),
);
}
public function enqueue_block_assets() {
if ( ! $this->is_module_active() ) {
return;
}
if ( ! class_exists( 'WooCommerce' ) ) {
return;
}
if ( ! ( merchant_is_cart_block_layout() && is_cart() ) ) {
return;
}
wp_enqueue_style(
'merchant-cart-reserved-timer',
MERCHANT_URI . "assets/css/modules/cart-reserved-timer/cart-reserved-timer.min.css",
array(),
MERCHANT_VERSION
);
wp_enqueue_script(
'merchant-pro-cart-reserved-timer-blocks',
MERCHANT_PRO_URI . 'assets/js/modules/cart-reserved-timer/cart-reserved-timer-blocks.min.js',
array( 'wp-element', 'wp-plugins', 'wc-blocks-registry', 'jquery', 'wp-data' ),
MERCHANT_PRO_VERSION,
true
);
}
public function is_module_active() {
return Merchant_Modules::is_module_active( self::MODULE_ID );
}
}
Merchant_Pro_Cart_Reserved_Timer_Blocks_Integration::get_instance()->load_hooks();Create: Merchant-Pro/assets/js/src/modules/cart-reserved-timer/cart-reserved-timer-blocks.js
This follows the same pattern as free-shipping-progress-bar-blocks.js but simplified — no placement logic needed, the timer always renders before the cart items.
/**
* WooCommerce Blocks Cart Reserved Timer Integration
*/
(function (wp, $) {
'use strict';
if (!wp || !wp.data) {
return;
}
const { select, subscribe } = wp.data;
const { createElement: h, render, useRef, useEffect } = wp.element;
const STORE_CART = 'wc/store/cart';
const NAMESPACE = 'merchant/v1/cart-reserved-timer';
const rootsMap = new WeakMap();
const renderComponent = (component, target) => {
if (wp.element.createRoot) {
if (!rootsMap.has(target)) {
rootsMap.set(target, wp.element.createRoot(target));
}
rootsMap.get(target).render(component);
} else {
render(component, target);
}
};
const TimerContainer = ({ html }) => {
const containerRef = useRef(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Initialize countdown from data attributes (reuses classic JS logic)
const $box = $(container).find('.merchant-cart-reserved-timer');
if ($box.length) {
const duration = $box.attr('data-duration');
const expires = $box.attr('data-expires');
const cookieTime = getCookie('merchant_cart_added_time');
const cartIsEmpty = $('.wc-block-cart__empty-cart').length > 0;
if (cookieTime && !cartIsEmpty) {
let expiry = new Date(parseInt(cookieTime) * 1000);
expiry.setMinutes(expiry.getMinutes() + parseInt(duration));
$box.show();
startCountdown($box, expiry, expires);
}
}
}, [html]);
return h('div', {
ref: containerRef,
className: 'merchant-cart-reserved-timer-blocks-container',
dangerouslySetInnerHTML: { __html: html }
});
};
// Cookie & countdown helpers (same logic as classic JS)
const getCookie = (name) => { /* ... same as classic ... */ };
const deleteCookie = (name) => { /* ... same as classic ... */ };
const startCountdown = ($box, expiry, expires) => {
const $timer = $box.find('.merchant-cart-reserved-timer-content-desc span');
const update = () => {
let remaining = expiry.getTime() - Date.now();
if (remaining <= 0) {
clearInterval(interval);
$timer.text('00:00');
if (expires === 'clear-cart') {
deleteCookie('merchant_cart_added_time');
// Use Store API to empty cart instead of page redirect
wp.data.dispatch(STORE_CART).emptyCart();
} else {
$box.hide();
}
return;
}
let m = Math.floor(remaining / 60000);
let s = Math.floor((remaining % 60000) / 1000);
$timer.text(m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0'));
// Toggle minute/second messages
if (m === 0) {
$box.find('.minutes').hide();
$box.find('.seconds').show();
} else {
$box.find('.minutes').show();
$box.find('.seconds').hide();
}
};
update();
const interval = setInterval(update, 1000);
};
const injectTimer = () => {
const hasResolved = select(STORE_CART).hasFinishedResolution('getCartData');
const cart = select(STORE_CART).getCartData();
if (!hasResolved || !cart || !cart.extensions) return;
const data = cart.extensions[NAMESPACE];
if (!data || !data.is_visible || !data.html) {
$('.merchant-cart-reserved-timer-blocks-injected').remove();
return;
}
const $target = $('.wc-block-cart').first();
if (!$target.length) return;
let $injected = $('.merchant-cart-reserved-timer-blocks-injected');
if (!$injected.length) {
$injected = $('<div>', { class: 'merchant-cart-reserved-timer-blocks-injected' });
$target.prepend($injected);
}
renderComponent(h(TimerContainer, { html: data.html }), $injected[0]);
};
subscribe(injectTimer, STORE_CART);
setTimeout(injectTimer, 100);
})(window.wp, window.jQuery);Key difference from classic: Timer expiry uses wp.data.dispatch('wc/store/cart').emptyCart() instead of the GET-parameter redirect. This keeps the block cart in sync via the Store API.
File: Merchant-Pro/inc/modules/cart-reserved-timer/class-cart-reserved-timer.php
Line 48-55, add a block-layout guard before hooking woocommerce_before_cart:
// Line 55 — wrap in a guard:
if ( ! ( function_exists( 'merchant_is_cart_block_layout' ) && merchant_is_cart_block_layout() ) ) {
add_action( 'woocommerce_before_cart', array( $this, 'add_template_to_cart' ) );
}Lines 83-93, guard enqueue_styles() and enqueue_scripts():
// Line 83 — enqueue_styles(), add at top:
public function enqueue_styles() {
if ( function_exists( 'merchant_is_cart_block_layout' ) && merchant_is_cart_block_layout() && is_cart() ) {
return; // Block integration handles its own assets
}
// ... existing code
}
// Line 97 — enqueue_scripts(), add at top:
public function enqueue_scripts() {
if ( function_exists( 'merchant_is_cart_block_layout' ) && merchant_is_cart_block_layout() && is_cart() ) {
return; // Block integration handles its own assets
}
// ... existing code
}File: Merchant/inc/modules/cart-reserved-timer/admin/options.php
Lines 14-25 — Remove the entire $before_fields block:
// DELETE these lines (14-25):
$before_fields = array();
if ( defined( 'MERCHANT_PRO_VERSION' ) && merchant_is_cart_block_layout() ) {
$before_fields = array(
'type' => 'warning',
'content' => sprintf( ... ),
);
}And line 48 in the fields array, remove the $before_fields entry:
// DELETE this line:
$before_fields,File: Merchant-Pro/inc/modules/cart-reserved-timer/class-cart-reserved-timer.php
After line 212 (before the closing }), or in the module autoloader — ensure the new blocks integration file is loaded:
// Add at the end of the file, before the init action (or use autoloader):
require_once __DIR__ . '/class-cart-reserved-timer-blocks-integration.php';Or if the module uses an autoloader, ensure the new class follows the naming convention and is registered.
| Aspect | Classic Cart | Block Cart |
|---|---|---|
| Timer HTML injection | woocommerce_before_cart hook |
Store API extensions → React injection |
| Cart clearing | ?merchant-wc-clear-cart GET → WC()->cart->empty_cart() |
wp.data.dispatch('wc/store/cart').emptyCart() |
| Cookie handling | PHP setcookie() + JS document.cookie |
Same — cookies are transport-agnostic |
| CSS/JS loading | Direct wp_enqueue_* |
Guarded: skip on block, load via blocks integration |
The cookie mechanism (merchant_cart_added_time) works identically in both contexts since it's purely client-server via HTTP headers. No changes needed there.
- Create
cart-reserved-timer-blocks.jsinassets/js/src/modules/cart-reserved-timer/ - Build minified version →
assets/js/modules/cart-reserved-timer/cart-reserved-timer-blocks.min.js - Create
class-cart-reserved-timer-blocks-integration.phpininc/modules/cart-reserved-timer/ - Apply the guards in
class-cart-reserved-timer.php - Remove warning banner from
options.php - Test with both classic
[woocommerce_cart]and Cart Block active
- Cart Block active: timer renders and counts down correctly
- Cart Block active: timer expires → cart empties via Store API (no page reload)
- Classic shortcode: timer still works as before (backward compat)
- Timer respects duration and message settings from admin
- Cookie persists across page navigations
- Admin warning banner no longer appears
- No JS/CSS loaded in classic mode when block is active (no double-loading)