Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 247 additions & 3 deletions assets/js/src/integrations/blocks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,229 @@
import { removeAction } from '@wordpress/hooks';
import { addUniqueAction } from '../utils';
import { addUniqueAction, getProductFromID } from '../utils';
import { ACTION_PREFIX, NAMESPACE } from '../constants';

/*
* Track whether the cart-remove-item hook fired recently.
* Used to prevent duplicate remove_from_cart events.
*/
let hookFiredRecently = false;

/**
* Get currency settings from our plugin's settings.
*
* @return {Object|undefined} Currency object with decimalSeparator, thousandSeparator, precision.
*/
const getCurrencySettings = () => window.ga4w?.settings?.currency;

/**
* Get the current cart data from WooCommerce's store (if available) or fallback to static data.
* The store provides fresh cart data that updates when items are added/removed via AJAX.
*
* @return {Object|null} Cart object with items array, or null if unavailable.
*/
const getCartData = () => {
// Try to get fresh cart data from WooCommerce Blocks store
if ( window.wp?.data?.select?.( 'wc/store/cart' ) ) {
const storeCart = window.wp.data
.select( 'wc/store/cart' )
.getCartData();
if ( storeCart?.items?.length > 0 ) {
return storeCart;
}
}

// Fallback to static cart data from page load
return window.ga4w?.data?.cart;
};

/**
* Parse a price string into a numeric value using currency settings.
*
* @param {string} priceText - The raw price text from DOM (e.g., "$1,234.56" or "1.234,56 €").
* @return {number} The parsed price as a float, or 0 if parsing failed.
*/
const parsePriceFromDOM = ( priceText ) => {
if ( ! priceText ) {
return 0;
}

const currency = getCurrencySettings();
// Currency settings should be always available this is only safe check
if ( ! currency ) {
return 0;
}

const { decimalSeparator = '.', thousandSeparator = ',' } = currency;

// Use WooCommerce's accounting.js library if available (most reliable)
if ( window.accounting?.unformat ) {
return window.accounting.unformat( priceText, decimalSeparator );
}

// Manual parsing using currency settings
// Remove currency symbols and whitespace, keeping only digits and separators
let cleaned = priceText.replace( /[^\d.,]/g, '' ).trim();

// Remove thousand separators
if ( thousandSeparator ) {
const escapedThousand = thousandSeparator.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
cleaned = cleaned.replace( new RegExp( escapedThousand, 'g' ), '' );
}

// Convert decimal separator to standard period for parseFloat
if ( decimalSeparator !== '.' ) {
cleaned = cleaned.replace( decimalSeparator, '.' );
}

return parseFloat( cleaned ) || 0;
};

/**
* Extract product data from DOM elements in a cart item row.
* This is a last-resort fallback when:
* - WooCommerce Blocks store (wc/store/cart) is unavailable or empty
* - Static cart data from server is unavailable
* - Product cannot be matched by ID or name in cart data
*
* @param {Element} cartItem - The cart item row element.
* @return {Object|null} Product object or null if extraction failed.
*/
const getProductFromDOM = ( cartItem ) => {
const productLink = cartItem.querySelector(
'.wc-block-components-product-name'
);
const productName = productLink?.textContent?.trim();

if ( ! productName ) {
return null;
}

// Try to get quantity from the quantity input or display
const quantityInput = cartItem.querySelector(
'.wc-block-components-quantity-selector__input'
);
const quantity = quantityInput
? parseInt( quantityInput.value, 10 ) || 1
: 1;

// Try to get price - look for the sale price first, then regular price
const priceElement =
cartItem.querySelector(
'.wc-block-components-product-price__value ins .woocommerce-Price-amount'
) ||
cartItem.querySelector(
'.wc-block-components-product-price__value .woocommerce-Price-amount'
) ||
cartItem.querySelector( '.wc-block-components-product-price__value' );

// Get currency minor unit from cart data or currency settings, default to 2
const cart = getCartData();
const currency = getCurrencySettings();
const currencyMinorUnit =
cart?.totals?.currency_minor_unit ?? currency?.precision ?? 2;

const price = priceElement
? parsePriceFromDOM( priceElement.textContent )
: 0;

// Build a minimal product object that works with getProductFieldObject
return {
name: productName,
quantity,
prices: {
price: Math.round( price * 10 ** currencyMinorUnit ),
currency_minor_unit: currencyMinorUnit,
},
};
};

/**
* Track when an item is removed from the Interactivity API-powered Mini Cart.
* The new Mini Cart (WooCommerce 10.4+) doesn't fire the experimental__woocommerce_blocks
* cart-remove-item action, so we need to listen for clicks on the remove button.
*
* This listener only fires if the hook hasn't already handled the removal to prevent
* duplicate events on older WooCommerce versions.
*
* @param {Function} getEventHandler - Function to get the event handler for a given event name.
*/
const trackMiniCartRemoval = ( getEventHandler ) => {
document.body.addEventListener( 'click', ( event ) => {
const removeButton = event.target.closest(
'.wc-block-cart-item__remove-link'
);

if ( ! removeButton ) {
return;
}

// Find the cart item container to get product information
const cartItem = removeButton.closest( '.wc-block-cart-items__row' );
if ( ! cartItem ) {
return;
}

let product = null;

/*
* Try to find product data from available sources:
* 1. WooCommerce Blocks store (wc/store/cart) - real-time cart state
* 2. Static cart data from server (window.ga4w.data.cart) - initial page load
* 3. DOM extraction - last resort when store data is unavailable
*/
const productLink = cartItem.querySelector(
'.wc-block-components-product-name'
);
const productHref = productLink?.getAttribute( 'href' );
const productName = productLink?.textContent?.trim();

// Extract product ID from URL (e.g., ?p=123)
let productId = null;
if ( productHref ) {
const paramMatch = productHref.match( /[?&]p=(\d+)/ );
if ( paramMatch ) {
productId = parseInt( paramMatch[ 1 ], 10 );
}
}

// Try WooCommerce store or static cart data
const cart = getCartData();
if ( cart?.items ) {
if ( productId ) {
product = getProductFromID( productId, [], cart );
} else if ( productName ) {
product = cart.items.find(
( item ) => item.name === productName
);
}
}

// Fallback: extract from DOM when cart data lookup fails
// This can happen if the cart store hasn't synced yet or product matching fails
if ( ! product ) {
product = getProductFromDOM( cartItem );
}

if ( product ) {
/*
* Use setTimeout to allow the hook to fire first (if it's going to).
* The hook sets hookFiredRecently=true synchronously, so by the time
* this callback runs, we'll know if the hook handled it.
*/
setTimeout( () => {
if ( ! hookFiredRecently ) {
getEventHandler( 'remove_from_cart' )( { product } );
}
// Reset the flag for the next removal
hookFiredRecently = false;
}, 0 );
}
} );
};

// We add actions asynchronosly, to make sure handlers will have the config available.
export const blocksTracking = ( getEventHandler ) => {
addUniqueAction(
Expand All @@ -13,13 +235,35 @@ export const blocksTracking = ( getEventHandler ) => {
addUniqueAction(
`${ ACTION_PREFIX }-cart-remove-item`,
NAMESPACE,
getEventHandler( 'remove_from_cart' )
( data ) => {
// Mark that the hook fired to prevent duplicate events from click listener
hookFiredRecently = true;
getEventHandler( 'remove_from_cart' )( data );
}
);

// Track Mini Cart removals for Interactivity API-powered Mini Cart (WooCommerce 10.4+)
trackMiniCartRemoval( getEventHandler );

addUniqueAction(
`${ ACTION_PREFIX }-checkout-render-checkout-form`,
NAMESPACE,
getEventHandler( 'begin_checkout' )
( data ) => {
/*
* WooCommerce 10.4+ may fire this event before cart data is fully loaded.
* If storeCart is empty or missing items, fall back to the cart data
* provided by the server via window.ga4w.data.cart.
*/
const storeCart = data?.storeCart;
const cartData =
storeCart?.items?.length > 0
? storeCart
: window.ga4w?.data?.cart;

if ( cartData ) {
getEventHandler( 'begin_checkout' )( { storeCart: cartData } );
}
}
);

// These actions only works for All Products Block
Expand Down
5 changes: 5 additions & 0 deletions includes/class-wc-google-gtag-js.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ public function inline_script_data(): void {
'tracker_function_name' => $this->tracker_function_name(),
'events' => $this->get_enabled_events(),
'identifier' => $this->get( 'ga_product_identifier' ),
'currency' => array(
'decimalSeparator' => wc_get_price_decimal_separator(),
'thousandSeparator' => wc_get_price_thousand_separator(),
'precision' => wc_get_price_decimals(),
),
),
JSON_HEX_TAG | JSON_UNESCAPED_SLASHES
),
Expand Down
62 changes: 58 additions & 4 deletions tests/e2e/specs/gtag-events/blocks-pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,63 @@ test.describe( 'GTag events on block pages', () => {

await event.then( ( request ) => {
const data = getEventData( request, 'remove_from_cart' );
expect( data.product1 ).toEqual( {
// Check common required fields
expect( data.product1 ).toMatchObject( {
id: simpleProductID.toString(),
nm: 'Simple product',
qt: '1',
pr: simpleProductPrice.toString(),
va: '',
} );
// Accept either category (WooCommerce 10.4+ fallback) or variant (older WooCommerce hook)
expect(
data.product1.ca === 'Uncategorized' || data.product1.va === ''
).toBe( true );
} );
} );

test( 'Remove from cart DOM fallback parses price correctly', async ( {
page,
} ) => {
await simpleProductAddToCart( page, simpleProductID );
await page.goto( 'shop' );
await page.locator( '.wc-block-mini-cart' ).click();

// Wait for mini cart to be visible
await page
.locator( '.wc-block-cart-item__remove-link' )
.first()
.waitFor();

// Force DOM fallback by clearing cart data sources
// Currency settings from ga4w.settings.currency are still available
await page.evaluate( () => {
window.ga4w.data.cart = null;
// Mock wp.data.select to return empty cart
if ( window.wp?.data?.select ) {
const originalSelect = window.wp.data.select;
window.wp.data.select = ( store ) => {
if ( store === 'wc/store/cart' ) {
return { getCartData: () => ( { items: [] } ) };
}
return originalSelect( store );
};
}
} );

const event = trackGtagEvent( page, 'remove_from_cart' );
await page
.locator( '.wc-block-cart-item__remove-link' )
.first()
.click();

await event.then( ( request ) => {
const data = getEventData( request, 'remove_from_cart' );
// DOM fallback should parse price correctly using ga4w.settings.currency
expect( data.product1.nm ).toEqual( 'Simple product' );
expect( data.product1.qt ).toEqual( '1' );
expect( parseFloat( data.product1.pr ) ).toEqual(
simpleProductPrice
);
} );
} );

Expand All @@ -145,13 +195,17 @@ test.describe( 'GTag events on block pages', () => {

await event.then( ( request ) => {
const data = getEventData( request, 'begin_checkout' );
expect( data.product1 ).toEqual( {
// Check common required fields
expect( data.product1 ).toMatchObject( {
id: simpleProductID.toString(),
nm: 'Simple product',
qt: '1',
pr: simpleProductPrice.toString(),
va: '',
} );
// Accept either category (WooCommerce 10.4+ fallback) or variant (older WooCommerce hook)
expect(
data.product1.ca === 'Uncategorized' || data.product1.va === ''
).toBe( true );
expect( data.cu ).toEqual( 'USD' );
expect( data[ 'epn.value' ] ).toEqual(
simpleProductPrice.toString()
Expand Down