Skip to content

Instantly share code, notes, and snippets.

@DevWael
Created April 17, 2026 17:04
Show Gist options
  • Select an option

  • Save DevWael/018376b355e26880c05a14523ae518c5 to your computer and use it in GitHub Desktop.

Select an option

Save DevWael/018376b355e26880c05a14523ae518c5 to your computer and use it in GitHub Desktop.
Issue #678: Cart Reserved Timer - WooCommerce Cart Block Compatibility Analysis

Issue #678 — Cart Reserved Timer: WooCommerce Cart Block Compatibility

Repo: awesomemotive/merchant-pro Type: Enhancement / Compatibility Labels: enhancement, compatibility Author: DevWael


1. Problem Statement

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.

2. Why the Incompatibility Exists

2.1 HTML Injection: Classic Hook vs. Store API

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.

2.2 The Warning Banner (Current Workaround)

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.

2.3 No Guard on Classic Asset Loading

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.


3. Existing Patterns in the Codebase

Two modules already implement Block Cart compatibility using the same architectural pattern:

3.1 Free Shipping Progress Bar (Best Reference)

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:

  1. Registers Store API endpoint data via woocommerce_store_api_register_endpoint_data() on woocommerce_blocks_loaded
  2. data_callback renders the PHP template via ob_start() / ob_get_clean() and returns HTML + metadata
  3. A dedicated React-compatible JS file subscribes to wc/store/cart, reads cart.extensions[namespace], and injects the HTML into the DOM
  4. Classic hooks are guarded with merchant_is_cart_block_layout() checks

3.2 Cart & Checkout Offers

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.


4. Implementation Plan

4.1 New File: Blocks Integration PHP

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();

4.2 New File: Blocks JS

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.

4.3 Modifications to Existing Files

4.3.1 Guard Classic Hooks on Block Layouts

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
}

4.3.2 Remove the Warning Banner

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,

4.3.3 Load the Blocks Integration Class

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.


5. Clear Cart — Block vs. Classic

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.


6. Build Steps

  1. Create cart-reserved-timer-blocks.js in assets/js/src/modules/cart-reserved-timer/
  2. Build minified version → assets/js/modules/cart-reserved-timer/cart-reserved-timer-blocks.min.js
  3. Create class-cart-reserved-timer-blocks-integration.php in inc/modules/cart-reserved-timer/
  4. Apply the guards in class-cart-reserved-timer.php
  5. Remove warning banner from options.php
  6. Test with both classic [woocommerce_cart] and Cart Block active

7. Testing Checklist

  • 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment