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
18 changes: 16 additions & 2 deletions modules/ppcp-blocks/resources/js/checkout-block.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
paypalSubscriptionButtonAllowed,
} from './Helper/Subscription';
import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading';
import { initCartFragmentSync } from '../../../ppcp-button/resources/js/modules/Helper/CartFragmentSync';
import BlockCheckoutMessagesBootstrap from './Bootstrap/BlockCheckoutMessagesBootstrap';
import { PayPalComponent } from './Components/paypal';
import { BlockEditorPayPalComponent } from './Components/block-editor-paypal';
Expand All @@ -19,17 +20,30 @@ const config = wc.wcSettings.getSetting( 'ppcp-gateway_data' );

window.ppcpFundingSource = config.fundingSource;

// Keep the classic header mini-cart count in sync with the block cart/checkout,
// which mutate the Store API cart instead of firing WooCommerce's jQuery cart
// fragment events. Idempotent with the same call in the button bundle.
initCartFragmentSync();

let paypalScriptPromise = null;

const features = [ 'products' ];
// Mirror the gateway's server-side (mode-aware) `supports` so WooCommerce Blocks
// does not filter the PayPal method out when the cart requires a feature the
// gateway actually supports — notably `multiple_subscriptions` when the cart holds
// two or more subscriptions. Falls back to the previous hard-coded list.
const features = Array.isArray( config.supportedFeatures )
? [ ...config.supportedFeatures ]
: [ 'products' ];
let blockEnabled = true;

if ( cartHasSubscriptionProducts( config.scriptData ) ) {
// Show the button only for subscription carts PayPal can process
// (shared rule used by the classic cart and mini-cart as well).
blockEnabled = paypalSubscriptionButtonAllowed( config.scriptData );

features.push( 'subscriptions' );
if ( ! Array.isArray( config.supportedFeatures ) ) {
features.push( 'subscriptions' );
}
}

if ( blockEnabled ) {
Expand Down
4 changes: 4 additions & 0 deletions modules/ppcp-blocks/src/PayPalPaymentMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ public function get_payment_method_data() {
'placeOrderButtonText' => $this->place_order_button_text,
'placeOrderButtonDescription' => $this->place_order_button_description,
'enabledFundingSources' => $funding_sources,
// The gateway's (mode-aware) supported features, so the block can
// declare them to WooCommerce Blocks and not be filtered out when the
// cart requires one (e.g. `multiple_subscriptions` for 2+ subscriptions).
'supportedFeatures' => array_values( (array) $this->gateway->supports ),
'ajax' => array(
'update_shipping' => array(
'endpoint' => WC_AJAX::get_endpoint( UpdateShippingEndpoint::ENDPOINT ),
Expand Down
6 changes: 6 additions & 0 deletions modules/ppcp-button/resources/js/button.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import { loadPaypalScript } from './modules/Helper/ScriptLoading';
import buttonModuleWatcher from './modules/ButtonModuleWatcher';
import MessagesBootstrap from './modules/ContextBootstrap/MessagesBootstrap';
import { apmButtonsInit } from './modules/Helper/ApmButtons';
import { initCartFragmentSync } from './modules/Helper/CartFragmentSync';

// Keep the classic header mini-cart count in sync with the block cart. Runs at
// module scope (not gated by bootstrap or DOMContentLoaded) so it works in every
// context regardless of button gating or script timing.
initCartFragmentSync();

// TODO: could be a good idea to have a separate spinner for each gateway,
// but I think we care mainly about the script loading, so one spinner should be enough.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { debounce } from '@ppcp-blocks/Helper/debounce';

/**
* Reads the current cart signature (item count + total) from the block Store
* API data store, or null when the store is not available.
*
* @param {Object} cart The `wc/store/cart` selector.
* @return {string|null} A stable signature string, or null.
*/
const readCartSignature = ( cart ) => {
if ( ! cart ) {
return null;
}

const cartData = cart.getCartData?.() ?? null;
const itemsCount = cartData?.itemsCount ?? cart.getItemsCount?.() ?? null;
if ( itemsCount === null ) {
return null;
}

// Include the total so coupon/total-only changes also refresh the widget.
const total = cartData?.totals?.total_price ?? '';

return `${ itemsCount }:${ total }`;
};

/**
* Keeps the classic header mini-cart (`.cart-contents`) count in sync with the
* block Cart/Checkout.
*
* The block cart mutates the `wc/store/cart` data store instead of firing the
* jQuery events (`wc_fragment_refresh`, `added_to_cart`, ...) that WooCommerce's
* `wc-cart-fragments` script listens to. On classic-theme headers this leaves
* the mini-cart count stale after items are added/removed in the block cart.
* Here we watch the store and trigger a fragment refresh when the cart changes.
*
* Idempotent: several entry points call this (the button bundle and the block
* integration bundle) so it works regardless of which one runs on a given page;
* only the first call subscribes. It is a no-op on pages without the Store API.
*
* @return {void}
*/
export const initCartFragmentSync = () => {
if ( typeof wp === 'undefined' || ! wp.data?.subscribe ) {
return;
}

// Only subscribe once, even when called from multiple bundles.
if ( window.ppcpCartFragmentSyncActive ) {
return;
}
window.ppcpCartFragmentSyncActive = true;

// Coalesce bursts of store updates into a single fragment refresh.
const refreshFragments = debounce( () => {
if ( typeof jQuery !== 'undefined' ) {
jQuery( document.body ).trigger( 'wc_fragment_refresh' );
}
}, 300 );

// The `wc/store/cart` store may not be registered yet at load time (block
// scripts load asynchronously), so subscribe unconditionally and seed the
// signature lazily on the first notification where the store is ready.
let seeded = false;
let lastSignature = null;

wp.data.subscribe( () => {
const signature = readCartSignature(
wp.data.select?.( 'wc/store/cart' )
);
if ( signature === null ) {
return;
}

// Seed on the first available read so page load does not refresh.
if ( ! seeded ) {
seeded = true;
lastSignature = signature;
return;
}

if ( signature === lastSignature ) {
return;
}
lastSignature = signature;

refreshFragments();
} );
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* global describe, test, expect, beforeEach, afterEach, jest */
import { initCartFragmentSync } from './CartFragmentSync';

describe( 'initCartFragmentSync', () => {
let subscribedCallback;
let cartData;
let triggerSpy;
let storeAvailable;

const makeCart = () => ( {
getCartData: () => cartData,
getItemsCount: () => cartData?.itemsCount ?? null,
} );

const setup = () => {
subscribedCallback = null;
storeAvailable = true;

global.wp = {
data: {
select: jest.fn( ( store ) =>
store === 'wc/store/cart' && storeAvailable
? makeCart()
: null
),
subscribe: jest.fn( ( cb ) => {
subscribedCallback = cb;
} ),
},
};

triggerSpy = jest.fn();
global.jQuery = jest.fn( () => ( { trigger: triggerSpy } ) );
};

beforeEach( () => {
jest.useFakeTimers();
delete window.ppcpCartFragmentSyncActive;
cartData = { itemsCount: 2, totals: { total_price: '2000' } };
setup();
} );

afterEach( () => {
jest.useRealTimers();
delete global.wp;
delete global.jQuery;
delete window.ppcpCartFragmentSyncActive;
} );

// Fires one store notification and flushes the debounced refresh.
const fireStoreUpdate = () => {
subscribedCallback();
jest.advanceTimersByTime( 300 );
};

// The first notification only seeds the baseline; call this before asserting
// on subsequent changes.
const seed = () => fireStoreUpdate();

test( 'is a no-op when wp is undefined', () => {
delete global.wp;
expect( () => initCartFragmentSync() ).not.toThrow();
} );

test( 'never refreshes while the cart store is unavailable', () => {
storeAvailable = false;
initCartFragmentSync();

// Subscription is registered even though the store is not ready yet.
expect( global.wp.data.subscribe ).toHaveBeenCalled();

fireStoreUpdate();
fireStoreUpdate();
expect( triggerSpy ).not.toHaveBeenCalled();
} );

test( 'does not refresh on the initial (seeding) notification', () => {
initCartFragmentSync();
seed();
expect( triggerSpy ).not.toHaveBeenCalled();
} );

test( 'triggers wc_fragment_refresh when the item count changes', () => {
initCartFragmentSync();
seed();

cartData = { itemsCount: 0, totals: { total_price: '0' } };
fireStoreUpdate();

expect( triggerSpy ).toHaveBeenCalledTimes( 1 );
expect( triggerSpy ).toHaveBeenCalledWith( 'wc_fragment_refresh' );
} );

test( 'triggers on total-only changes (e.g. coupon) without count change', () => {
initCartFragmentSync();
seed();

cartData = { itemsCount: 2, totals: { total_price: '1500' } };
fireStoreUpdate();

expect( triggerSpy ).toHaveBeenCalledTimes( 1 );
} );

test( 'does not trigger when the cart signature is unchanged', () => {
initCartFragmentSync();
seed();
fireStoreUpdate();
expect( triggerSpy ).not.toHaveBeenCalled();
} );

test( 'seeds lazily on the first read after the store becomes available', () => {
storeAvailable = false;
initCartFragmentSync();

// Notifications before the store is ready neither seed nor refresh.
fireStoreUpdate();

// Store becomes available: first ready read seeds, no refresh.
storeAvailable = true;
fireStoreUpdate();
expect( triggerSpy ).not.toHaveBeenCalled();

// A subsequent change now refreshes.
cartData = { itemsCount: 0, totals: { total_price: '0' } };
fireStoreUpdate();
expect( triggerSpy ).toHaveBeenCalledTimes( 1 );
} );
} );
Loading