Commit 9a014727a0b for woocommerce
commit 9a014727a0b8d822a2e4ce51d8a28682e6c0feb4
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date: Fri May 22 18:56:09 2026 +0100
Merge `feature/shopper-collections` into `trunk` (#65263)
diff --git a/plugins/woocommerce/changelog/64771-fix-shopper-collection-variation-hydration b/plugins/woocommerce/changelog/64771-fix-shopper-collection-variation-hydration
new file mode 100644
index 00000000000..f5714cbdd5a
--- /dev/null
+++ b/plugins/woocommerce/changelog/64771-fix-shopper-collection-variation-hydration
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Interactivity API hydration warning on shopper-collection rows for items without product variations.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/64776-fix-cart-save-for-later-visibility b/plugins/woocommerce/changelog/64776-fix-cart-save-for-later-visibility
new file mode 100644
index 00000000000..93ec66a8957
--- /dev/null
+++ b/plugins/woocommerce/changelog/64776-fix-cart-save-for-later-visibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Hide the cart "Save for later" link when the user is logged out, the `cart_save_for_later` feature is disabled, or the cart page has no `woocommerce/shopper-collection` block.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/64807-hide-non-public-products-in-lists b/plugins/woocommerce/changelog/64807-hide-non-public-products-in-lists
new file mode 100644
index 00000000000..71770ab623e
--- /dev/null
+++ b/plugins/woocommerce/changelog/64807-hide-non-public-products-in-lists
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Don't leak title, price, permalink, or image for non-public products (draft, private, password-protected, etc.) in shopper-list responses.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/65179-refactor-saved-for-later-standalone b/plugins/woocommerce/changelog/65179-refactor-saved-for-later-standalone
new file mode 100644
index 00000000000..d8c1b9ce22d
--- /dev/null
+++ b/plugins/woocommerce/changelog/65179-refactor-saved-for-later-standalone
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Saved for Later: split standalone block from the Shopper Collection variation; shared shopper-lists REST + iAPI domain remains reusable for future list-type blocks.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/add-add-to-wishlist-button-block b/plugins/woocommerce/changelog/add-add-to-wishlist-button-block
new file mode 100644
index 00000000000..6150790e608
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-add-to-wishlist-button-block
@@ -0,0 +1,5 @@
+Significance: minor
+Type: add
+Comment: Experimental Add to Wishlist button block, gated by the `product_wishlist` feature flag.
+
+Add the `woocommerce/add-to-wishlist-button` block: shipped as an inner block of `woocommerce/add-to-cart-with-options` on the single-product template (block themes only). Toggles the currently configured product (parent or selected variation) in the shopper's wishlist via the shared `shopper-lists` iAPI store. Hidden for guests.
diff --git a/plugins/woocommerce/changelog/add-cart-save-for-later-link b/plugins/woocommerce/changelog/add-cart-save-for-later-link
new file mode 100644
index 00000000000..076b97eb734
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-cart-save-for-later-link
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a "Save for later" link beneath each cart line item that saves the item to the saved-for-later shopper list and removes it from the cart on success. The link is only shown to logged-in users and is hidden in the mini-cart.
diff --git a/plugins/woocommerce/changelog/add-shopper-collection-heading b/plugins/woocommerce/changelog/add-shopper-collection-heading
new file mode 100644
index 00000000000..c9544bf9494
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collection-heading
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Shopper Collection: add a built-in heading inner block (defaults to "Saved for later" at H2). Editable in the block editor.
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-cart-hook b/plugins/woocommerce/changelog/add-shopper-collections-cart-hook
new file mode 100644
index 00000000000..bdb7a5c73b1
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-cart-hook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Auto-inject the Shopper Collection block on the cart page when the shopper lists feature is enabled.
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-feature-flag b/plugins/woocommerce/changelog/add-shopper-collections-feature-flag
new file mode 100644
index 00000000000..8e50071a012
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-feature-flag
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Gate shopper lists behind a new experimental flag.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-iapi-store b/plugins/woocommerce/changelog/add-shopper-collections-iapi-store
new file mode 100644
index 00000000000..c3832e63ebe
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-iapi-store
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Wire the Shopper Collection (Saved-for-later) block to the shopper-lists Store API via a private iAPI store, with server-side rendering of saved items, optimistic remove, and Move-to-cart for simple products.
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-wishlists-base b/plugins/woocommerce/changelog/add-shopper-collections-wishlists-base
new file mode 100644
index 00000000000..f7a7efd9d11
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-wishlists-base
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Shopper Collections: add experimental wishlist support.
diff --git a/plugins/woocommerce/changelog/add-wishlist-block b/plugins/woocommerce/changelog/add-wishlist-block
new file mode 100644
index 00000000000..95fa3752fed
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wishlist-block
@@ -0,0 +1,5 @@
+Significance: minor
+Type: add
+Comment: Experimental Wishlist block, gated by the `product_wishlist` feature flag.
+
+Add the `woocommerce/wishlist` block: renders the shopper's wishlist via the shared `shopper-lists` Store API and iAPI store, composes the `ShopperListRenderer` shared row markup, and ships an "Add to cart" per-row action that adds the product to the cart and removes the row from the wishlist on confirmed success. The `/my-account/wishlist/` endpoint now renders this block instead of its placeholder.
diff --git a/plugins/woocommerce/changelog/feature-shopper-collection-empty-message-gating b/plugins/woocommerce/changelog/feature-shopper-collection-empty-message-gating
new file mode 100644
index 00000000000..e3f0c116d91
--- /dev/null
+++ b/plugins/woocommerce/changelog/feature-shopper-collection-empty-message-gating
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: hide the empty-state message until items have appeared in the list during the current page session.
diff --git a/plugins/woocommerce/changelog/fix-saved-for-later-tests-legacy-proxy-leak b/plugins/woocommerce/changelog/fix-saved-for-later-tests-legacy-proxy-leak
new file mode 100644
index 00000000000..f411bce7a14
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-saved-for-later-tests-legacy-proxy-leak
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Test-only change; no user-facing impact.
+
+Saved for Later tests: reset the LegacyProxy `is_cart()` mock in a `finally` block so it can't leak into later tests sharing the same process.
diff --git a/plugins/woocommerce/changelog/fix-shopper-collection-atomic-styles b/plugins/woocommerce/changelog/fix-shopper-collection-atomic-styles
new file mode 100644
index 00000000000..58cd1d542f4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shopper-collection-atomic-styles
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: declare the atomic product-image, product-price and product-button stylesheets as style dependencies so their CSS is enqueued on the frontend whenever the block renders, instead of relying on duplicated/missing rules.
diff --git a/plugins/woocommerce/changelog/fix-shopper-collection-drop-editor-stylesheet b/plugins/woocommerce/changelog/fix-shopper-collection-drop-editor-stylesheet
new file mode 100644
index 00000000000..2d2e31a5c3a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shopper-collection-drop-editor-stylesheet
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: drop the redundant editor stylesheet and rely on the frontend one for both the editor and the frontend.
diff --git a/plugins/woocommerce/changelog/fix-shopper-collection-hooked-block-flag b/plugins/woocommerce/changelog/fix-shopper-collection-hooked-block-flag
new file mode 100644
index 00000000000..ddacac9edea
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shopper-collection-hooked-block-flag
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Set `cartPageHasSavedForLater` from the Shopper Collection block itself so the cart "Save for later" link shows when the block is auto-injected as a hooked block (which `has_block()` does not detect).
diff --git a/plugins/woocommerce/changelog/hide-shopper-collection-logged-out b/plugins/woocommerce/changelog/hide-shopper-collection-logged-out
new file mode 100644
index 00000000000..ce1d88c2212
--- /dev/null
+++ b/plugins/woocommerce/changelog/hide-shopper-collection-logged-out
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Shopper Collection: skip rendering and asset enqueue for logged-out shoppers.
diff --git a/plugins/woocommerce/changelog/refactor-extract-shopper-list-renderer b/plugins/woocommerce/changelog/refactor-extract-shopper-list-renderer
new file mode 100644
index 00000000000..18e221777a5
--- /dev/null
+++ b/plugins/woocommerce/changelog/refactor-extract-shopper-list-renderer
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Shopper lists: extract the shared row markup (image, name, price, remove badge, variation overlay) into a `ShopperListRenderer` helper and a `_shopper-lists/item` SCSS partial. Saved for Later now composes the helper; the same scaffold is what the upcoming Wishlist block will reuse.
diff --git a/plugins/woocommerce/changelog/refactor-shopper-list-schemas-take-objects b/plugins/woocommerce/changelog/refactor-shopper-list-schemas-take-objects
new file mode 100644
index 00000000000..85e9b311d87
--- /dev/null
+++ b/plugins/woocommerce/changelog/refactor-shopper-list-schemas-take-objects
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Pass ShopperList and ShopperListItem objects directly to their Store API schemas instead of round-tripping through to_array(); no JSON response shape change.
diff --git a/plugins/woocommerce/changelog/replace-shopper-collection-error-row-with-store-notice b/plugins/woocommerce/changelog/replace-shopper-collection-error-row-with-store-notice
new file mode 100644
index 00000000000..cdb9c3aef18
--- /dev/null
+++ b/plugins/woocommerce/changelog/replace-shopper-collection-error-row-with-store-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: surface mutation errors via the store-notices banner instead of an in-list error row, and disable the affected row's controls while the request is in flight.
diff --git a/plugins/woocommerce/changelog/rsm-shopper-collections-add-storeapi-endpoints b/plugins/woocommerce/changelog/rsm-shopper-collections-add-storeapi-endpoints
new file mode 100644
index 00000000000..18fc33d8d5b
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-shopper-collections-add-storeapi-endpoints
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the foundation for shopper lists via new Store API endpoints under /wc/store/v1/shopper-lists. Lists are persisted per-user in usermeta.
diff --git a/plugins/woocommerce/changelog/rsm-shopper-collections-nonce-stopgap b/plugins/woocommerce/changelog/rsm-shopper-collections-nonce-stopgap
new file mode 100644
index 00000000000..d286adc4777
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-shopper-collections-nonce-stopgap
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Require a wc_store_api Nonce header on shopper-lists item writes.
diff --git a/plugins/woocommerce/changelog/tweak-shopper-collection-custom-grid b/plugins/woocommerce/changelog/tweak-shopper-collection-custom-grid
new file mode 100644
index 00000000000..a85ea8248f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/tweak-shopper-collection-custom-grid
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: own the grid layout (custom `columnCount` attribute + `.columns-N` class) and ship Product-Collection-equivalent responsive behavior. Items now reflow to fewer columns on narrow viewports instead of shrinking uniformly.
diff --git a/plugins/woocommerce/client/blocks/.eslintrc.js b/plugins/woocommerce/client/blocks/.eslintrc.js
index fcf80dc83cb..499f9858a7d 100644
--- a/plugins/woocommerce/client/blocks/.eslintrc.js
+++ b/plugins/woocommerce/client/blocks/.eslintrc.js
@@ -134,6 +134,7 @@ const coreModules = [
'@woocommerce/stores/store-notices',
'@woocommerce/stores/woocommerce/cart',
'@woocommerce/stores/woocommerce/products',
+ '@woocommerce/stores/woocommerce/shopper-lists',
'@woocommerce/tracks',
'@woocommerce/data',
'@woocommerce/customer-effort-score',
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
index a05ab74801f..7e55fb004af 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
@@ -11,6 +11,7 @@ import {
useStoreCartItemQuantity,
useStoreEvents,
useStoreCart,
+ useSaveForLater,
} from '@woocommerce/base-context/hooks';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import {
@@ -19,8 +20,8 @@ import {
} from '@woocommerce/blocks-checkout';
import { forwardRef, useMemo } from '@wordpress/element';
import type { CartItem } from '@woocommerce/types';
-import { objectHasProp, Currency } from '@woocommerce/types';
-import { getSetting } from '@woocommerce/settings';
+import { isBoolean, objectHasProp, Currency } from '@woocommerce/types';
+import { getSetting, getSettingWithCoercion } from '@woocommerce/settings';
import { Icon, trash } from '@wordpress/icons';
import { calculateSaleAmount } from '@woocommerce/base-utils';
import { dinero, transformScale, toSnapshot, type Dinero } from 'dinero.js';
@@ -117,7 +118,31 @@ const CartLineItemRow: React.ForwardRefExoticComponent<
const { quantity, setItemQuantity, removeItem, isPendingDelete } =
useStoreCartItemQuantity( lineItem );
+ const { saveForLater, isSaving: isSavingForLater } = useSaveForLater();
const { dispatchStoreEvent } = useStoreEvents();
+ const isUserLoggedIn = !! getSetting< number >( 'currentUserId', 0 );
+ const isSaveForLaterFeatureEnabled = getSettingWithCoercion(
+ 'experimentalCartSaveForLater',
+ false,
+ isBoolean
+ );
+ const cartPageHasSavedForLater = getSettingWithCoercion(
+ 'cartPageHasSavedForLater',
+ false,
+ isBoolean
+ );
+ // Three signals, each catching a distinct failure mode.
+ // Disabling the `cart_save_for_later` feature unregisters the
+ // saved-for-later block but leaves any prior insertion in the
+ // cart page's post content (the editor renders it as an
+ // "unsupported block" notice) — so presence alone could render
+ // this link with no working destination. Inversely, the feature
+ // can be enabled on cart pages that never inserted the block.
+ // And the REST endpoints behind the click are auth-only.
+ const showSaveForLater =
+ isUserLoggedIn &&
+ isSaveForLaterFeatureEnabled &&
+ cartPageHasSavedForLater;
// Prepare props to pass to the applyCheckoutFilter filter.
// We need to pluck out receiveCart.
@@ -339,6 +364,59 @@ const CartLineItemRow: React.ForwardRefExoticComponent<
</button>
) }
</div>
+ { showSaveForLater && (
+ <div className="wc-block-cart-item__save-for-later">
+ <button
+ type="button"
+ className="wc-block-cart-item__save-for-later-link"
+ onClick={ async () => {
+ const saved = await saveForLater(
+ lineItem.key
+ );
+ if ( ! saved ) {
+ return;
+ }
+ // removeItem surfaces its own errors
+ // via processErrorResponse; we still
+ // fire the analytics event and a11y
+ // announcement to mirror the regular
+ // remove flow.
+ await removeItem();
+ // TODO: consider a dedicated
+ // 'cart-save-for-later' store event so
+ // analytics can distinguish a save
+ // from a plain remove.
+ dispatchStoreEvent(
+ 'cart-remove-item',
+ {
+ product: lineItem,
+ quantity,
+ }
+ );
+ speak(
+ sprintf(
+ /* translators: %s refers to the item name. */
+ __(
+ '%s has been saved for later and removed from your cart.',
+ 'woocommerce'
+ ),
+ name
+ )
+ );
+ } }
+ disabled={
+ isPendingDelete || isSavingForLater
+ }
+ >
+ { isSavingForLater
+ ? __( 'Saving…', 'woocommerce' )
+ : __(
+ 'Save for later',
+ 'woocommerce'
+ ) }
+ </button>
+ </div>
+ ) }
</div>
</td>
<td className="wc-block-cart-item__total">
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss
index a125901d08a..6bf603630d0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss
@@ -96,6 +96,21 @@ table.wc-block-cart-items {
}
}
}
+ .wc-block-cart-item__save-for-later {
+ .wc-block-cart-item__save-for-later-link {
+ @include link-button();
+ @include hover-effect();
+ @include font-small-locked;
+
+ text-transform: none;
+ white-space: nowrap;
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.5;
+ }
+ }
+ }
.wc-block-components-product-name {
@include font-small-locked;
display: block;
@@ -119,6 +134,10 @@ table.wc-block-cart-items {
transition: opacity 200ms ease;
}
}
+
+ &.wc-block-mini-cart-items .wc-block-cart-item__save-for-later {
+ display: none;
+ }
}
@mixin cart-items-table-styles() {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
new file mode 100644
index 00000000000..2d9bb1e32f5
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
@@ -0,0 +1,247 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import type { CartItem } from '@woocommerce/types';
+import { getSetting, getSettingWithCoercion } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import CartLineItemRow from '../cart-line-item-row';
+
+jest.mock( '@woocommerce/settings', () => ( {
+ ...jest.requireActual( '@woocommerce/settings' ),
+ // Default implementations return the provided defaultValue so module-load
+ // time calls (e.g. `getSetting( 'wcBlocksConfig', { pluginUrl: '', ... } )`
+ // in the settings constants module) still receive a usable shape. Per-test
+ // overrides via `setSettings` below replace these implementations.
+ getSetting: jest.fn(
+ ( _key: string, defaultValue: unknown ) => defaultValue
+ ),
+ getSettingWithCoercion: jest.fn(
+ ( _key: string, defaultValue: unknown ) => defaultValue
+ ),
+} ) );
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+ ...jest.requireActual( '@woocommerce/base-context/hooks' ),
+ useStoreCartItemQuantity: jest.fn( () => ( {
+ quantity: 1,
+ setItemQuantity: jest.fn(),
+ removeItem: jest.fn(),
+ isPendingDelete: false,
+ } ) ),
+ useStoreEvents: jest.fn( () => ( {
+ dispatchStoreEvent: jest.fn(),
+ } ) ),
+ useStoreCart: jest.fn( () => ( {
+ receiveCart: jest.fn(),
+ cartItems: [],
+ } ) ),
+ useSaveForLater: jest.fn( () => ( {
+ saveForLater: jest.fn(),
+ isSaving: false,
+ } ) ),
+} ) );
+
+jest.mock( '@woocommerce/blocks-checkout', () => ( {
+ ...jest.requireActual( '@woocommerce/blocks-checkout' ),
+ applyCheckoutFilter: jest.fn( ( { defaultValue } ) => defaultValue ),
+ productPriceValidation: jest.fn(),
+} ) );
+
+const mockGetSetting = getSetting as jest.Mock;
+const mockGetSettingWithCoercion = getSettingWithCoercion as jest.Mock;
+
+/**
+ * Minimal CartItem shape — only the fields the row actually reads. Using an
+ * inline fixture instead of `previewCart` keeps this test free of the
+ * preview module's transitive imports (which expect `wcBlocksConfig` to be
+ * populated at module-load time).
+ */
+const buildLineItem = (): CartItem =>
+ ( {
+ key: 'test-key',
+ id: 1,
+ type: 'simple',
+ quantity: 1,
+ catalog_visibility: 'visible',
+ name: 'Test product',
+ short_description: '',
+ description: '',
+ sku: 'test-sku',
+ permalink: 'https://example.org',
+ low_stock_remaining: null,
+ backorders_allowed: false,
+ show_backorder_badge: false,
+ sold_individually: false,
+ quantity_limits: {
+ minimum: 1,
+ maximum: 99,
+ multiple_of: 1,
+ editable: true,
+ },
+ images: [],
+ variation: [],
+ item_data: [],
+ prices: {
+ currency_code: 'USD',
+ currency_minor_unit: 2,
+ currency_symbol: '$',
+ currency_prefix: '$',
+ currency_suffix: '',
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ price: '1000',
+ regular_price: '1000',
+ sale_price: '1000',
+ price_range: null,
+ raw_prices: {
+ precision: 6,
+ price: '10000000',
+ regular_price: '10000000',
+ sale_price: '10000000',
+ },
+ },
+ totals: {
+ currency_code: 'USD',
+ currency_minor_unit: 2,
+ currency_symbol: '$',
+ currency_prefix: '$',
+ currency_suffix: '',
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ line_subtotal: '1000',
+ line_subtotal_tax: '0',
+ line_total: '1000',
+ line_total_tax: '0',
+ },
+ extensions: {},
+ } as unknown as CartItem );
+
+/**
+ * Configure `getSetting` and `getSettingWithCoercion` mocks for the three
+ * signals the cart row reads when deciding whether to render the
+ * "Save for later" button. Any setting not explicitly provided falls back to
+ * the default supplied by the caller (same behaviour as the production
+ * helpers).
+ */
+const setSettings = ( {
+ currentUserId = 0,
+ experimentalCartSaveForLater,
+ cartPageHasSavedForLater,
+}: {
+ currentUserId?: number;
+ experimentalCartSaveForLater?: boolean;
+ cartPageHasSavedForLater?: boolean;
+} ) => {
+ mockGetSetting.mockImplementation(
+ ( key: string, defaultValue: unknown ) => {
+ if ( key === 'currentUserId' ) {
+ return currentUserId;
+ }
+ return defaultValue;
+ }
+ );
+ mockGetSettingWithCoercion.mockImplementation(
+ ( key: string, defaultValue: unknown ) => {
+ if (
+ key === 'experimentalCartSaveForLater' &&
+ experimentalCartSaveForLater !== undefined
+ ) {
+ return experimentalCartSaveForLater;
+ }
+ if (
+ key === 'cartPageHasSavedForLater' &&
+ cartPageHasSavedForLater !== undefined
+ ) {
+ return cartPageHasSavedForLater;
+ }
+ return defaultValue;
+ }
+ );
+};
+
+const renderRow = () => {
+ // Wrap in a table so the row's <td>s render in a valid context.
+ return render(
+ <table>
+ <tbody>
+ <CartLineItemRow lineItem={ buildLineItem() } />
+ </tbody>
+ </table>
+ );
+};
+
+describe( 'CartLineItemRow — Save for later visibility', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'shows the Save for later button when user is logged in, feature is enabled, and the page has a saved-for-later block', () => {
+ setSettings( {
+ currentUserId: 42,
+ experimentalCartSaveForLater: true,
+ cartPageHasSavedForLater: true,
+ } );
+
+ renderRow();
+
+ expect(
+ screen.getByRole( 'button', { name: /save for later/i } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'hides the Save for later button when the user is logged out', () => {
+ setSettings( {
+ currentUserId: 0,
+ experimentalCartSaveForLater: true,
+ cartPageHasSavedForLater: true,
+ } );
+
+ renderRow();
+
+ expect(
+ screen.queryByRole( 'button', { name: /save for later/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'hides the Save for later button when the page has no saved-for-later block', () => {
+ setSettings( {
+ currentUserId: 42,
+ experimentalCartSaveForLater: true,
+ cartPageHasSavedForLater: false,
+ } );
+
+ renderRow();
+
+ expect(
+ screen.queryByRole( 'button', { name: /save for later/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'hides the Save for later button when the feature flag is disabled', () => {
+ setSettings( {
+ currentUserId: 42,
+ experimentalCartSaveForLater: false,
+ cartPageHasSavedForLater: true,
+ } );
+
+ renderRow();
+
+ expect(
+ screen.queryByRole( 'button', { name: /save for later/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'hides the Save for later button when none of the signals are set (defaults)', () => {
+ setSettings( {} );
+
+ renderRow();
+
+ expect(
+ screen.queryByRole( 'button', { name: /save for later/i } )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts
index 8f510164b6b..4f125fd2e71 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts
@@ -1,3 +1,4 @@
export * from './use-store-cart';
export * from './use-store-cart-coupons';
export * from './use-store-cart-item-quantity';
+export * from './use-save-for-later';
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
new file mode 100644
index 00000000000..54501e584bf
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { dispatch, useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { cartStore } from '@woocommerce/block-data';
+
+/**
+ * Save a cart line item to the saved-for-later shopper list.
+ *
+ * Dispatches the wp.data cart store's `saveForLater` thunk, which POSTs the
+ * item and emits a `wc-blocks_store_sync_required` event. A Saved for Later
+ * block on the same page picks up that event in its iAPI store and applies
+ * the new row reactively.
+ *
+ * Resolves to `true` on success so the caller can chain the cart removal;
+ * on failure surfaces an error notice in the cart context and resolves to
+ * `false`. The cart removal is the caller's responsibility — keep the two
+ * awaits separate so save and remove errors stay distinct.
+ */
+export const useSaveForLater = (): {
+ isSaving: boolean;
+ saveForLater: ( cartItemKey: string ) => Promise< boolean >;
+} => {
+ const [ isSaving, setIsSaving ] = useState( false );
+ const { saveForLater: dispatchSaveForLater } = useDispatch( cartStore );
+
+ const saveForLater = useCallback(
+ async ( cartItemKey: string ): Promise< boolean > => {
+ if ( ! cartItemKey || isSaving ) {
+ return false;
+ }
+ setIsSaving( true );
+ try {
+ await dispatchSaveForLater( cartItemKey );
+ return true;
+ } catch ( error ) {
+ const message =
+ error &&
+ typeof error === 'object' &&
+ 'message' in error &&
+ typeof ( error as { message: unknown } ).message ===
+ 'string'
+ ? ( error as { message: string } ).message
+ : __(
+ 'There was a problem saving this item for later.',
+ 'woocommerce'
+ );
+ dispatch( noticesStore ).createNotice( 'error', message, {
+ context: 'wc/cart',
+ isDismissible: true,
+ } );
+ return false;
+ } finally {
+ setIsSaving( false );
+ }
+ },
+ [ isSaving, dispatchSaveForLater ]
+ );
+
+ return { isSaving, saveForLater };
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
new file mode 100644
index 00000000000..523b1ca8944
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
@@ -0,0 +1,347 @@
+/**
+ * External dependencies
+ */
+import { store } from '@wordpress/interactivity';
+import type { AsyncAction, TypeYield } from '@wordpress/interactivity';
+import type { CurrencyResponse } from '@woocommerce/types';
+import type { Store as StoreNotices } from '@woocommerce/stores/store-notices';
+
+/**
+ * Mirror of `Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListItemSchema::get_properties()`.
+ *
+ * Keep this in sync with the schema. State here must not include any UI-derived
+ * fields — display values belong in block-private stores or PHP SSR.
+ * TO DO: decide where UI-derived state lives
+ */
+export type ShopperListItemImage = {
+ id: number;
+ src: string;
+ thumbnail: string;
+ srcset: string;
+ sizes: string;
+ name: string;
+ alt: string;
+ thumbnail_srcset: string;
+ thumbnail_sizes: string;
+};
+
+export type ShopperListItemVariation = {
+ raw_attribute: string;
+ attribute: string;
+ value: string;
+};
+
+export type ShopperListItemPrices = CurrencyResponse & {
+ price: string;
+ regular_price: string;
+ sale_price: string;
+};
+
+export type RawShopperListItem = {
+ key: string;
+ id: number;
+ product_id: number;
+ variation_id: number;
+ quantity: number;
+ is_live: boolean;
+ is_purchasable: boolean;
+ name: string;
+ permalink: string | null;
+ images: ShopperListItemImage[];
+ variation: ShopperListItemVariation[];
+ prices: ShopperListItemPrices | null;
+ price_html: string;
+ image_html: string;
+ date_added_gmt: string;
+};
+
+export type ShopperListState = {
+ items: RawShopperListItem[];
+ isLoading: boolean;
+};
+
+export type AddItemPayload = {
+ product_id?: number;
+ cart_item_key?: string;
+ variation?: Array< { attribute: string; value: string } >;
+ quantity?: number;
+};
+
+export type Store = {
+ state: {
+ restUrl: string;
+ // TODO: revisit nonce handling when we look at authentication for
+ // the shopper-lists routes. Today PHP seeds this via
+ // `wp_create_nonce( 'wc_store_api' )` and we refresh it from
+ // response headers (see restRequest below). Likely changes once
+ // the routes start enforcing nonces server-side: align with the
+ // cart store's bootstrap-from-response-header pattern, share the
+ // cart's `state.nonce` instead of duplicating, or move to a
+ // caching-friendlier transport.
+ nonce: string;
+ lists: Record< string, ShopperListState >;
+ };
+ actions: {
+ loadList: ( slug: string ) => Promise< void >;
+ addItem: ( slug: string, payload: AddItemPayload ) => Promise< void >;
+ removeItem: ( slug: string, key: string ) => Promise< void >;
+ showNoticeError: ( error: Error ) => Promise< void >;
+ };
+};
+
+// Stores are locked to prevent 3PD usage until the API is stable.
+const universalLock =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const isShopperListItem = ( value: unknown ): value is RawShopperListItem =>
+ !! value &&
+ typeof value === 'object' &&
+ typeof ( value as { key?: unknown } ).key === 'string';
+
+const ensureListState = (
+ state: Store[ 'state' ],
+ slug: string
+): ShopperListState => {
+ let list = state.lists[ slug ];
+ if ( ! list ) {
+ list = { items: [], isLoading: false };
+ state.lists[ slug ] = list;
+ }
+ return list;
+};
+
+/**
+ * Send a Store API request following the cart store's auth shape:
+ * Nonce header, `wc_store_api` action on the server side, cookie auth via
+ * `credentials: 'include'`, and `cache: 'no-store'` so user-specific data is
+ * never cached.
+ *
+ * The starter nonce is seeded by PHP via `wp_interactivity_state` and
+ * refreshed from the `Nonce` response header on every subsequent request,
+ * so the server-side enforcement (landing in a follow-up PR) can be
+ * flipped on without rewriting the client.
+ */
+async function restRequest< T >(
+ state: Store[ 'state' ],
+ path: string,
+ init: RequestInit = {}
+): Promise< T | null > {
+ const headers = new Headers( init.headers );
+ headers.set( 'Content-Type', 'application/json' );
+ if ( state.nonce ) {
+ headers.set( 'Nonce', state.nonce );
+ }
+
+ const response = await fetch( `${ state.restUrl }${ path }`, {
+ ...init,
+ headers,
+ cache: 'no-store',
+ credentials: 'include',
+ } );
+
+ const nextNonce = response.headers.get( 'Nonce' );
+ if ( nextNonce ) {
+ state.nonce = nextNonce;
+ }
+
+ if ( response.status === 204 ) {
+ return null;
+ }
+
+ const text = await response.text();
+ const contentType = response.headers.get( 'Content-Type' ) || '';
+ const json =
+ text && contentType.includes( 'json' ) ? JSON.parse( text ) : null;
+
+ if ( ! response.ok ) {
+ const message =
+ ( json && typeof json === 'object' && 'message' in json
+ ? String( ( json as { message: unknown } ).message )
+ : '' ) ||
+ response.statusText ||
+ 'Request failed.';
+ throw new Error( message );
+ }
+
+ return json as T | null;
+}
+
+// Do NOT supply `nonce` / `restUrl` defaults here. iAPI's deep-merge has the
+// JS-supplied state win over the existing (PHP-seeded) state for primitives,
+// so an empty-string default would clobber the values seeded server-side via
+// `wp_interactivity_state`. State for those fields comes purely from PHP. Same
+// reason the cart store doesn't ship state defaults — see cart.ts.
+const { state, actions } = store< Store >(
+ 'woocommerce/shopper-lists',
+ {
+ actions: {
+ *loadList( slug: string ): AsyncAction< void > {
+ const list = ensureListState( state, slug );
+ list.isLoading = true;
+
+ try {
+ const response = ( yield restRequest<
+ RawShopperListItem[]
+ >(
+ state,
+ `wc/store/v1/shopper-lists/${ encodeURIComponent(
+ slug
+ ) }/items`,
+ { method: 'GET' }
+ ) ) as TypeYield<
+ typeof restRequest< RawShopperListItem[] >
+ >;
+
+ if ( ! Array.isArray( response ) ) {
+ throw new Error( 'Invalid shopper list response.' );
+ }
+
+ const items = response.filter( isShopperListItem );
+
+ // TODO: track in-flight mutation count and skip applying
+ // load results when mutations are pending, so a slow
+ // loadList cannot clobber a fresh add/remove.
+ list.items = items;
+ } catch ( error ) {
+ // No user trigger to attach a banner to; log for ops.
+ // eslint-disable-next-line no-console
+ console.error( error );
+ } finally {
+ list.isLoading = false;
+ }
+ },
+
+ *addItem(
+ slug: string,
+ payload: AddItemPayload
+ ): AsyncAction< void > {
+ const list = ensureListState( state, slug );
+
+ try {
+ const item = ( yield restRequest< RawShopperListItem >(
+ state,
+ `wc/store/v1/shopper-lists/${ encodeURIComponent(
+ slug
+ ) }/items`,
+ {
+ method: 'POST',
+ body: JSON.stringify( payload ),
+ }
+ ) ) as TypeYield<
+ typeof restRequest< RawShopperListItem >
+ >;
+
+ if ( ! isShopperListItem( item ) ) {
+ throw new Error(
+ 'Invalid shopper list item response.'
+ );
+ }
+
+ // Merge the returned item by key — replace if present,
+ // append otherwise. Re-saving the same product POSTs
+ // twice and the server merges quantity, so we mirror
+ // that behaviour locally.
+ const existingIndex = list.items.findIndex(
+ ( i ) => i.key === item.key
+ );
+ if ( existingIndex >= 0 ) {
+ list.items[ existingIndex ] = item;
+ } else {
+ list.items.push( item );
+ }
+ } catch ( error ) {
+ actions.showNoticeError( error as Error );
+ }
+ },
+
+ *removeItem( slug: string, key: string ): AsyncAction< void > {
+ const list = state.lists[ slug ];
+ if ( ! list ) {
+ return;
+ }
+
+ if ( list.items.findIndex( ( i ) => i.key === key ) < 0 ) {
+ return;
+ }
+
+ // Pessimistic remove: leave the row in place until the
+ // server confirms, so failures don't flash. Buttons are
+ // disabled meanwhile via the block's `pendingKeys`.
+ try {
+ yield restRequest(
+ state,
+ `wc/store/v1/shopper-lists/${ encodeURIComponent(
+ slug
+ ) }/items/${ encodeURIComponent( key ) }`,
+ { method: 'DELETE' }
+ );
+ } catch ( error ) {
+ actions.showNoticeError( error as Error );
+ return;
+ }
+
+ // Re-find — the list may have mutated during the await.
+ const removedIndex = list.items.findIndex(
+ ( i ) => i.key === key
+ );
+ if ( removedIndex >= 0 ) {
+ list.items.splice( removedIndex, 1 );
+ }
+ },
+
+ // Mirrors `cart.ts::showNoticeError`.
+ *showNoticeError( error: Error ): AsyncAction< void > {
+ yield import( '@woocommerce/stores/store-notices' );
+ const { actions: noticeActions } = store< StoreNotices >(
+ 'woocommerce/store-notices',
+ {},
+ { lock: universalLock }
+ );
+
+ noticeActions.addNotice( {
+ notice: error.message,
+ type: 'error',
+ dismissible: true,
+ } );
+
+ // eslint-disable-next-line no-console
+ console.error( error );
+ },
+ },
+ },
+ { lock: universalLock }
+);
+
+// Listen for shopper-list item additions emitted from the wp.data side (e.g.
+// the cart store's saveForLater thunk). Mirrors the cart's iAPI → wp.data
+// sync direction, which also ships a payload (`from_iAPI` carries
+// `quantityChanges`). The event carries the saved item directly so we can
+// splice it in without an extra GET — keeps the merge ordering deterministic
+// and avoids the loadList-vs-mutation race the iAPI store's loadList still
+// has a TODO about.
+//
+// Keeps the discriminator + payload contract in sync with
+// `assets/js/data/cart/thunks.ts::saveForLater`.
+window.addEventListener( 'wc-blocks_store_sync_required', ( event: Event ) => {
+ const detail = ( event as CustomEvent ).detail as
+ | { type?: string; slug?: string; item?: RawShopperListItem }
+ | undefined;
+ if ( detail?.type !== 'shopper-list-item-added' ) {
+ return;
+ }
+ if (
+ typeof detail.slug !== 'string' ||
+ detail.slug.trim().length === 0 ||
+ ! isShopperListItem( detail.item )
+ ) {
+ return;
+ }
+ const list = ensureListState( state, detail.slug );
+ const item = detail.item;
+ const existingIndex = list.items.findIndex( ( i ) => i.key === item.key );
+ if ( existingIndex >= 0 ) {
+ list.items[ existingIndex ] = item;
+ } else {
+ list.items.push( item );
+ }
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/block.json
new file mode 100644
index 00000000000..da8995cce57
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/block.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "woocommerce/add-to-wishlist-button",
+ "version": "1.0.0",
+ "title": "Add to Wishlist Button",
+ "description": "Lets shoppers add the current product to their wishlist from the single-product page.",
+ "category": "woocommerce",
+ "keywords": [ "WooCommerce", "Wishlist" ],
+ "textdomain": "woocommerce",
+ "usesContext": [ "postId" ],
+ "ancestor": [ "woocommerce/add-to-cart-with-options" ],
+ "supports": {
+ "interactivity": true,
+ "html": false,
+ "reusable": false,
+ "multiple": false,
+ "spacing": {
+ "margin": true,
+ "padding": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ },
+ "color": {
+ "text": true,
+ "background": true
+ }
+ },
+ "viewScriptModule": "woocommerce/add-to-wishlist-button",
+ "style": [ "file:../woocommerce/add-to-wishlist-button-style.css" ]
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/edit.tsx
new file mode 100644
index 00000000000..eae7d961573
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/edit.tsx
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useBlockProps } from '@wordpress/block-editor';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+const Edit = (): JSX.Element => {
+ const blockProps = useBlockProps( {
+ className: 'wc-block-add-to-wishlist-button',
+ } );
+
+ return (
+ <div { ...blockProps }>
+ <button
+ type="button"
+ className="wc-block-add-to-wishlist-button__toggle"
+ disabled
+ >
+ <span className="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--empty">
+ <Icon icon={ starEmpty } size={ 24 } />
+ </span>
+ <span className="wc-block-add-to-wishlist-button__label">
+ { __( 'Add to wishlist', 'woocommerce' ) }
+ </span>
+ </button>
+ </div>
+ );
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
new file mode 100644
index 00000000000..1c639be4d9a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
@@ -0,0 +1,227 @@
+/**
+ * External dependencies
+ */
+import {
+ getConfig,
+ getContext,
+ store,
+ type AsyncAction,
+} from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
+import '@woocommerce/stores/woocommerce/shopper-lists';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import type { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
+import type {
+ RawShopperListItem,
+ Store as ShopperListsStore,
+} from '@woocommerce/stores/woocommerce/shopper-lists';
+
+/**
+ * Internal dependencies
+ */
+import { matchVariationItem } from './match-variation-item';
+
+const universalLock =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const LIST_SLUG = 'wishlist';
+
+type ButtonConfig = {
+ addLabel: string;
+ savedLabel: string;
+ selectOptionsLabel: string;
+};
+
+type BlockContext = {
+ productId: number;
+ isVariableType: boolean;
+ // Mid-click flag, gated per-block so the button can be disabled while
+ // the request is in flight. Single-instance block, no `pendingKeys`
+ // map needed (Wishlist/SFL use one because they're per-row).
+ isPending: boolean;
+};
+
+// The narrow slice of ATCWO's iAPI context this block consumes. Reuses
+// `SelectedAttributes` from the cart store — the same type ATCWO uses for
+// its own `selectedAttributes` context field — so any shape change there
+// (e.g. adding a `taxonomy` field) flows through here automatically.
+type ATCWOContext = {
+ selectedAttributes: SelectedAttributes[];
+};
+
+type BlockStore = {
+ state: {
+ effectiveProductId: number;
+ currentItem: RawShopperListItem | null;
+ isInWishlist: boolean;
+ isDisabled: boolean;
+ currentLabel: string;
+ };
+ actions: {
+ onClickToggle: () => Generator< unknown, void >;
+ };
+};
+
+const { state: productsState } = store< ProductsStore >(
+ 'woocommerce/products',
+ {},
+ { lock: universalLock }
+);
+
+const { state: shopperListsState, actions: shopperListsActions } =
+ store< ShopperListsStore >(
+ 'woocommerce/shopper-lists',
+ {},
+ { lock: universalLock }
+ );
+
+const { state } = store< BlockStore >(
+ 'woocommerce/add-to-wishlist-button',
+ {
+ state: {
+ // For variable products, the effective product is the selected
+ // variation — resolved through the products store's
+ // `productInContext` derived getter, which already encapsulates
+ // "variation if one is selected, otherwise the parent." Returns
+ // 0 when the current resolution is still the variable parent
+ // (i.e. the shopper hasn't picked attributes yet), which
+ // `isDisabled` reads as "not yet selectable."
+ get effectiveProductId(): number {
+ const product = productsState.productInContext;
+ if ( ! product ) {
+ return 0;
+ }
+ const context = getContext< BlockContext >();
+ if ( context.isVariableType && product.type === 'variable' ) {
+ return 0;
+ }
+ return product.id;
+ },
+
+ get currentItem(): RawShopperListItem | null {
+ const id = state.effectiveProductId;
+ if ( ! id ) {
+ return null;
+ }
+ const list = shopperListsState.lists[ LIST_SLUG ];
+ if ( ! list ) {
+ return null;
+ }
+ const context = getContext< BlockContext >();
+ // For non-variable products, id alone uniquely identifies
+ // the wishlist row. For variable products with "any"
+ // attribute slots, several attribute combinations can map
+ // to the same variation product, so we additionally
+ // disambiguate by the shopper's picked attributes — see
+ // `matchVariationItem` for details.
+ if ( ! context.isVariableType ) {
+ return (
+ list.items.find( ( item ) => item.id === id ) ?? null
+ );
+ }
+ const addToCartContext = getContext< ATCWOContext >(
+ 'woocommerce/add-to-cart-with-options'
+ );
+ const selected = addToCartContext?.selectedAttributes ?? [];
+ return (
+ list.items.find( ( item ) =>
+ matchVariationItem( item, id, selected )
+ ) ?? null
+ );
+ },
+
+ get isInWishlist(): boolean {
+ return state.currentItem !== null;
+ },
+
+ get isDisabled(): boolean {
+ const context = getContext< BlockContext >();
+ if ( context.isPending ) {
+ return true;
+ }
+ return ! state.effectiveProductId;
+ },
+
+ get currentLabel(): string {
+ const { addLabel, savedLabel, selectOptionsLabel } = getConfig(
+ 'woocommerce/add-to-wishlist-button'
+ ) as ButtonConfig;
+
+ if ( ! state.effectiveProductId ) {
+ return selectOptionsLabel;
+ }
+ return state.isInWishlist ? savedLabel : addLabel;
+ },
+ },
+
+ actions: {
+ *onClickToggle(): AsyncAction< void > {
+ const context = getContext< BlockContext >();
+ if ( context.isPending ) {
+ return;
+ }
+ const id = state.effectiveProductId;
+ if ( ! id ) {
+ return;
+ }
+
+ const existing = state.currentItem;
+ context.isPending = true;
+ try {
+ if ( existing ) {
+ yield shopperListsActions.removeItem(
+ LIST_SLUG,
+ existing.key
+ );
+ } else {
+ // We inherit ATCWO's iAPI context because this block
+ // is an inner block of ATCWO (enforced by
+ // `ancestor` in `block.json`). That lets us read
+ // the shopper-picked attributes — needed for
+ // variations with "any" slots, where the server
+ // can't resolve the line item without them.
+ //
+ // ATCWO stores them by display label ("Color"), but
+ // the shopper-lists route expects taxonomy slugs
+ // ("pa_color"). Map via the parent product's
+ // `attributes` table; fall back to the raw name for
+ // non-taxonomy custom attributes.
+ //
+ // TODO: drop this mapping once ATCWO exposes the
+ // taxonomy on `selectedAttributes` directly.
+ const addToCartContext = getContext< ATCWOContext >(
+ 'woocommerce/add-to-cart-with-options'
+ );
+ const parent = productsState.mainProductInContext;
+ const attrMap = new Map< string, string >();
+ parent?.attributes?.forEach(
+ ( a: {
+ name?: string;
+ taxonomy?: string | null;
+ } ) => {
+ if ( a.name ) {
+ attrMap.set( a.name, a.taxonomy || a.name );
+ }
+ }
+ );
+ const variation =
+ addToCartContext?.selectedAttributes?.map(
+ ( { attribute, value } ) => ( {
+ attribute:
+ attrMap.get( attribute ) ?? attribute,
+ value,
+ } )
+ ) ?? [];
+ yield shopperListsActions.addItem( LIST_SLUG, {
+ product_id: id,
+ ...( variation.length && { variation } ),
+ } );
+ }
+ } finally {
+ context.isPending = false;
+ }
+ },
+ },
+ },
+ { lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/index.tsx
new file mode 100644
index 00000000000..21f24c7656a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/index.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+import './style.scss';
+
+registerBlockType( metadata, {
+ icon: {
+ src: (
+ <Icon
+ icon={ starEmpty }
+ className="wc-block-editor-components-block-icon"
+ />
+ ),
+ },
+ edit: Edit,
+ save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
new file mode 100644
index 00000000000..78369a4b093
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
@@ -0,0 +1,61 @@
+/**
+ * Items in the wishlist whose variation values we can compare against the
+ * shopper's currently picked attributes. Narrowed from `RawShopperListItem`
+ * so this helper stays pure (no iAPI deps) and unit-testable in isolation.
+ */
+type MatchableItem = {
+ id: number;
+ variation?: Array< {
+ attribute: string;
+ value: string;
+ } > | null;
+};
+
+/**
+ * The shopper's currently picked attributes — same shape as ATCWO's
+ * `selectedAttributes` context entries (also re-exported from the cart store
+ * as `SelectedAttributes`).
+ */
+type SelectedPair = {
+ attribute: string;
+ value: string;
+};
+
+/**
+ * Decide whether a wishlist item represents the exact variation + attribute
+ * combination the shopper has currently picked. For fully-constrained
+ * variations a single `id` match is enough, but for "any" attribute slots
+ * several attribute combinations can share the same variation product (and
+ * therefore the same `item.id`), so we additionally compare the attribute
+ * sets. The asymmetric `value` comparison is case-insensitive because the
+ * Store API returns each entry's `value` as the term display name ("Red")
+ * while ATCWO carries the term slug ("red") in `selectedAttributes`.
+ *
+ * Edge cases out of scope here (would need a slug→name lookup via the parent
+ * product's `terms`): slugs whose shape differs from the display name beyond
+ * capitalization, e.g. `"bright-red"` vs `"Bright Red"`.
+ *
+ * @param item Wishlist item from the shopper-lists store.
+ * @param id Effective product/variation id we're comparing against.
+ * @param selected Shopper's picked attribute/value pairs.
+ */
+export function matchVariationItem(
+ item: MatchableItem,
+ id: number,
+ selected: SelectedPair[]
+): boolean {
+ if ( item.id !== id ) {
+ return false;
+ }
+ const stored = item.variation ?? [];
+ if ( stored.length !== selected.length ) {
+ return false;
+ }
+ return selected.every( ( sel ) =>
+ stored.some(
+ ( v ) =>
+ v.attribute === sel.attribute &&
+ v.value.toLowerCase() === sel.value.toLowerCase()
+ )
+ );
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
new file mode 100644
index 00000000000..cdbc6befb56
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
@@ -0,0 +1,7 @@
+/**
+ * The block is rendered server-side and has no inner blocks, so there's
+ * nothing to serialise to `post_content`.
+ */
+const Save = (): null => null;
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/style.scss
new file mode 100644
index 00000000000..3e662b8502c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/style.scss
@@ -0,0 +1,42 @@
+// Frontend stylesheet for the Add to Wishlist button block. Mirrors the
+// link-button cue the cart's per-row Save-for-later button uses — a subtle
+// inline button that sits under the primary Add to Cart CTA.
+
+.wc-block-add-to-wishlist-button {
+ margin-top: 0.5em;
+
+ .wc-block-add-to-wishlist-button__toggle {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ gap: 0.25em;
+ padding: 0;
+ text-decoration: none;
+
+ &:hover:not(:disabled) {
+ text-decoration: underline;
+ }
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.5;
+ }
+ }
+
+ .wc-block-add-to-wishlist-button__icon {
+ display: inline-flex;
+
+ &[hidden] {
+ display: none;
+ }
+
+ svg {
+ height: 1.25em;
+ width: 1.25em;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/test/match-variation-item.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/test/match-variation-item.ts
new file mode 100644
index 00000000000..16b44907bba
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/test/match-variation-item.ts
@@ -0,0 +1,90 @@
+/**
+ * Internal dependencies
+ */
+import { matchVariationItem } from '../match-variation-item';
+
+describe( 'matchVariationItem', () => {
+ const redLarge = {
+ id: 42,
+ variation: [
+ { attribute: 'Color', value: 'Red' },
+ { attribute: 'Size', value: 'Large' },
+ ],
+ };
+ const redSmall = {
+ id: 42,
+ variation: [
+ { attribute: 'Color', value: 'Red' },
+ { attribute: 'Size', value: 'Small' },
+ ],
+ };
+
+ it( 'returns false when the item id differs', () => {
+ expect(
+ matchVariationItem( redLarge, 99, [
+ { attribute: 'Color', value: 'red' },
+ { attribute: 'Size', value: 'large' },
+ ] )
+ ).toBe( false );
+ } );
+
+ it( 'matches an "any" variation by attribute values when ids collide', () => {
+ // Two wishlist rows share id=42 (same variation product, different
+ // "any" slot values). The matcher must pick exactly the one whose
+ // attribute set matches the shopper's current selection.
+ const selectedLarge = [
+ { attribute: 'Color', value: 'red' },
+ { attribute: 'Size', value: 'large' },
+ ];
+
+ expect( matchVariationItem( redLarge, 42, selectedLarge ) ).toBe(
+ true
+ );
+ expect( matchVariationItem( redSmall, 42, selectedLarge ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'is case-insensitive on values (slug vs term display name)', () => {
+ // Store API returns `value: "Red"`; ATCWO writes `value: "red"`. The
+ // comparison must bridge the case difference.
+ expect(
+ matchVariationItem( redLarge, 42, [
+ { attribute: 'Color', value: 'red' },
+ { attribute: 'Size', value: 'large' },
+ ] )
+ ).toBe( true );
+ } );
+
+ it( 'returns false when the picked attribute set size differs', () => {
+ // Edge: shopper picks just Color; the row has both Color and Size.
+ expect(
+ matchVariationItem( redLarge, 42, [
+ { attribute: 'Color', value: 'red' },
+ ] )
+ ).toBe( false );
+ } );
+
+ it( 'treats a missing variation array as empty', () => {
+ // Server omits `variation` for items where the saved product is no
+ // longer purchasable. Should never spuriously match.
+ const noVariation = { id: 42 };
+ expect(
+ matchVariationItem( noVariation, 42, [
+ { attribute: 'Color', value: 'red' },
+ ] )
+ ).toBe( false );
+
+ // With no picked attributes and no stored variation, it's a match.
+ expect( matchVariationItem( noVariation, 42, [] ) ).toBe( true );
+ } );
+
+ it( 'returns false when an attribute name differs (label mismatch)', () => {
+ expect(
+ matchVariationItem( redLarge, 42, [
+ { attribute: 'Colour', value: 'red' },
+ { attribute: 'Size', value: 'large' },
+ ] )
+ ).toBe( false );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
new file mode 100644
index 00000000000..123521deb71
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "woocommerce/saved-for-later",
+ "version": "1.0.0",
+ "title": "Saved for Later",
+ "description": "Display items the shopper has saved from their cart for later.",
+ "category": "woocommerce",
+ "keywords": [ "WooCommerce", "Saved for Later" ],
+ "textdomain": "woocommerce",
+ "attributes": {
+ "columnCount": {
+ "type": "number",
+ "default": 5
+ }
+ },
+ "allowedBlocks": [ "core/heading" ],
+ "supports": {
+ "align": [ "wide", "full" ],
+ "interactivity": true,
+ "html": false,
+ "reusable": false,
+ "spacing": {
+ "margin": true,
+ "padding": true,
+ "blockGap": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ },
+ "color": {
+ "text": true,
+ "background": true
+ }
+ },
+ "viewScriptModule": "woocommerce/saved-for-later",
+ "style": [
+ "file:../woocommerce/saved-for-later-style.css",
+ "wc-blocks-style-product-image",
+ "woocommerce-product-price-style",
+ "woocommerce-product-button-style"
+ ]
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
new file mode 100644
index 00000000000..2e2d4ea718f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
@@ -0,0 +1,189 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ useBlockProps,
+ useInnerBlocksProps,
+ InspectorControls,
+} from '@wordpress/block-editor';
+import { PanelBody, RangeControl } from '@wordpress/components';
+import { Icon, trash } from '@wordpress/icons';
+import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
+
+interface SavedForLaterAttributes {
+ columnCount: number;
+}
+
+interface EditProps {
+ attributes: SavedForLaterAttributes;
+ setAttributes: ( attrs: Partial< SavedForLaterAttributes > ) => void;
+}
+
+const MIN_COLUMNS = 2;
+const MAX_COLUMNS = 6;
+
+// Lives in JS because `__()` is needed for the heading copy. `block.json`
+// strings aren't run through translation, so keeping the template here
+// is the only way to ship a localized default.
+const TEMPLATE: [ string, Record< string, unknown > ][] = [
+ [
+ 'core/heading',
+ { content: __( 'Saved for later', 'woocommerce' ), level: 2 },
+ ],
+];
+
+const PREVIEW_ITEMS = [
+ {
+ key: 'preview-1',
+ name: __( 'Sample product one', 'woocommerce' ),
+ variation: __( 'Size: M', 'woocommerce' ),
+ price: '$19.99',
+ quantity: __( 'Qty: 2', 'woocommerce' ),
+ },
+ {
+ key: 'preview-2',
+ name: __( 'Sample product two', 'woocommerce' ),
+ variation: __( 'Color: Blue', 'woocommerce' ),
+ price: '$29.99',
+ quantity: __( 'Qty: 1', 'woocommerce' ),
+ },
+ {
+ key: 'preview-3',
+ name: __( 'Sample product three', 'woocommerce' ),
+ variation: '',
+ price: '$9.99',
+ quantity: __( 'Qty: 3', 'woocommerce' ),
+ },
+ {
+ key: 'preview-4',
+ name: __( 'Sample product four', 'woocommerce' ),
+ variation: __( 'Size: L', 'woocommerce' ),
+ price: '$24.99',
+ quantity: __( 'Qty: 1', 'woocommerce' ),
+ },
+ {
+ key: 'preview-5',
+ name: __( 'Sample product five', 'woocommerce' ),
+ variation: '',
+ price: '$14.99',
+ quantity: __( 'Qty: 2', 'woocommerce' ),
+ },
+ {
+ key: 'preview-6',
+ name: __( 'Sample product six', 'woocommerce' ),
+ variation: __( 'Color: Red', 'woocommerce' ),
+ price: '$39.99',
+ quantity: __( 'Qty: 1', 'woocommerce' ),
+ },
+];
+
+const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
+ const { columnCount } = attributes;
+
+ const blockProps = useBlockProps( {
+ className: 'wc-block-saved-for-later',
+ } );
+
+ // `allowedBlocks` is read from block.json automatically — passing it
+ // here would just duplicate the declaration. `templateLock: false`
+ // is the default so we omit that too. The className matches the
+ // `<div>` PHP wraps `$content` in on the frontend, so any CSS keyed
+ // off `__header` applies in both contexts.
+ const innerBlocksProps = useInnerBlocksProps(
+ { className: 'wc-block-saved-for-later__header' },
+ { template: TEMPLATE }
+ );
+
+ return (
+ <>
+ <InspectorControls>
+ <PanelBody title={ __( 'Settings', 'woocommerce' ) }>
+ <RangeControl
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ label={ __( 'Columns', 'woocommerce' ) }
+ value={ columnCount }
+ onChange={ ( value?: number ) => {
+ if ( typeof value !== 'number' ) {
+ return;
+ }
+ setAttributes( { columnCount: value } );
+ } }
+ min={ MIN_COLUMNS }
+ max={ MAX_COLUMNS }
+ />
+ </PanelBody>
+ </InspectorControls>
+ <section { ...blockProps }>
+ <div { ...innerBlocksProps } />
+ <ul
+ className={ `wc-block-saved-for-later__list columns-${ columnCount }` }
+ >
+ { PREVIEW_ITEMS.map( ( item ) => (
+ <li
+ key={ item.key }
+ className="wc-block-shopper-list-item"
+ >
+ <div className="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+ <a
+ href="#preview"
+ onClick={ ( e ) => e.preventDefault() }
+ >
+ <img src={ PLACEHOLDER_IMG_SRC } alt="" />
+ </a>
+ <button
+ type="button"
+ className="wc-block-shopper-list-item__remove"
+ aria-label={ sprintf(
+ /* translators: %s: product name. */
+ __(
+ 'Remove %s from Saved for later list',
+ 'woocommerce'
+ ),
+ item.name
+ ) }
+ disabled
+ >
+ <Icon icon={ trash } size={ 24 } />
+ </button>
+ { item.variation && (
+ <span className="wc-block-shopper-list-item__variation">
+ { item.variation }
+ </span>
+ ) }
+ </div>
+ <h2 className="wp-block-post-title has-text-align-center has-medium-font-size">
+ <a
+ href="#preview"
+ onClick={ ( e ) => e.preventDefault() }
+ >
+ { item.name }
+ </a>
+ </h2>
+ <div className="price wc-block-components-product-price has-text-align-center has-small-font-size">
+ <span className="wc-block-components-product-price__value">
+ { item.price }
+ </span>
+ </div>
+ <span className="wc-block-shopper-list-item__quantity">
+ { item.quantity }
+ </span>
+ <div className="wp-block-button wc-block-components-product-button">
+ <button
+ type="button"
+ className="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+ disabled
+ >
+ { __( 'Move to cart', 'woocommerce' ) }
+ </button>
+ </div>
+ </li>
+ ) ) }
+ </ul>
+ </section>
+ </>
+ );
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
new file mode 100644
index 00000000000..c78f8148c4e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
@@ -0,0 +1,347 @@
+/**
+ * External dependencies
+ */
+import {
+ getConfig,
+ getContext,
+ getElement,
+ store,
+ type AsyncAction,
+} from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/shopper-lists';
+import '@woocommerce/stores/woocommerce/cart';
+import type {
+ RawShopperListItem,
+ Store as ShopperListsStore,
+} from '@woocommerce/stores/woocommerce/shopper-lists';
+import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import { sanitizeHTML } from '@woocommerce/sanitize';
+
+const universalLock =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const LIST_SLUG = 'saved-for-later';
+
+type SavedForLaterConfig = {
+ quantityLabelTemplate: string;
+ removeLabelTemplate: string;
+};
+
+type BlockContext = {
+ // Wrapper-scoped flag: starts as `items.length > 0` from SSR and the
+ // `trackShownItems` callback flips it to `true` the first time the
+ // list has any items at runtime. Lives in iAPI context so it resets
+ // on every full page load.
+ hasShownItems: boolean;
+ listItem?: RawShopperListItem;
+ htmlField?: 'price_html' | 'image_html';
+ // Item keys currently mid-mutation, used to disable per-row buttons.
+ pendingKeys: Record< string, true >;
+};
+
+type BlockStore = {
+ state: {
+ currentItems: RawShopperListItem[];
+ isCurrentItemPending: boolean;
+ isEmpty: boolean;
+ isMoveToCartHidden: boolean;
+ isPriceHidden: boolean;
+ currentItemDisplayName: string;
+ currentItemQuantityLabel: string;
+ currentItemRemoveLabel: string;
+ currentItemVariationLabel: string;
+ };
+ actions: {
+ onClickRemove: () => Generator< unknown, void >;
+ onClickMoveToCart: () => Generator< unknown, void >;
+ };
+ callbacks: {
+ updateInnerHtml: () => void;
+ trackShownItems: () => void;
+ };
+};
+
+// Allow-list for sanitizing the schema's preformatted strings on innerHTML
+// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
+// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
+// + dimensions + lazy loading).
+const ALLOWED_TAGS = [
+ 'a',
+ 'b',
+ 'em',
+ 'i',
+ 'strong',
+ 'p',
+ 'br',
+ 'span',
+ 'bdi',
+ 'del',
+ 'ins',
+ 'img',
+ 'picture',
+ 'source',
+];
+const ALLOWED_ATTR = [
+ 'class',
+ 'target',
+ 'href',
+ 'rel',
+ 'name',
+ 'download',
+ 'aria-hidden',
+ 'src',
+ 'srcset',
+ 'sizes',
+ 'alt',
+ 'width',
+ 'height',
+ 'loading',
+ 'decoding',
+];
+
+const { state: shopperListsState, actions: shopperListsActions } =
+ store< ShopperListsStore >(
+ 'woocommerce/shopper-lists',
+ {},
+ { lock: universalLock }
+ );
+
+const { state: cartState, actions: cartActions } = store< WooCommerce >(
+ 'woocommerce',
+ {},
+ { lock: universalLock }
+);
+
+const decodeEntities = ( encoded: string ): string => {
+ const txt = document.createElement( 'textarea' );
+ txt.innerHTML = encoded;
+ return txt.value;
+};
+
+const formatVariationLabel = ( item: RawShopperListItem ): string => {
+ if ( ! item.variation || item.variation.length === 0 ) {
+ return '';
+ }
+ return item.variation
+ .map(
+ ( v ) =>
+ `${ decodeEntities( v.attribute ) }: ${ decodeEntities(
+ v.value
+ ) }`
+ )
+ .join( ', ' );
+};
+
+const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;
+
+store< BlockStore >(
+ 'woocommerce/saved-for-later',
+ {
+ state: {
+ get currentItems(): RawShopperListItem[] {
+ return getList( LIST_SLUG )?.items ?? [];
+ },
+
+ get isCurrentItemPending(): boolean {
+ const { listItem, pendingKeys } = getContext< BlockContext >();
+ return !! listItem && !! pendingKeys[ listItem.key ];
+ },
+
+ get isEmpty(): boolean {
+ const list = getList( LIST_SLUG );
+ if ( ! list ) {
+ return false;
+ }
+ const ctx = getContext< BlockContext >();
+ return (
+ ctx.hasShownItems &&
+ ! list.isLoading &&
+ list.items.length === 0
+ );
+ },
+
+ get isPriceHidden(): boolean {
+ const { listItem } = getContext< BlockContext >();
+ return ! listItem?.price_html;
+ },
+
+ get isMoveToCartHidden(): boolean {
+ const { listItem } = getContext< BlockContext >();
+ if ( ! listItem ) {
+ return true;
+ }
+ return ! listItem.is_purchasable;
+ },
+
+ // `data-wp-text` writes its argument as text-content without
+ // running entity decoding, so a name returned by the schema as
+ // `Tom & Jerry` would render literally that way. Bind
+ // templates and SSR text spans to this getter instead of the
+ // raw context field so what the browser shows matches what
+ // PHP wrote on first paint.
+ get currentItemDisplayName(): string {
+ const { listItem } = getContext< BlockContext >();
+ return listItem ? decodeEntities( listItem.name ) : '';
+ },
+
+ get currentItemQuantityLabel(): string {
+ const { listItem } = getContext< BlockContext >();
+ if ( ! listItem ) {
+ return '';
+ }
+ const { quantityLabelTemplate } = getConfig(
+ 'woocommerce/saved-for-later'
+ ) as SavedForLaterConfig;
+ return quantityLabelTemplate.replace(
+ '%d',
+ String( listItem.quantity )
+ );
+ },
+
+ get currentItemRemoveLabel(): string {
+ const { listItem } = getContext< BlockContext >();
+ if ( ! listItem ) {
+ return '';
+ }
+ const { removeLabelTemplate } = getConfig(
+ 'woocommerce/saved-for-later'
+ ) as SavedForLaterConfig;
+ return removeLabelTemplate.replace(
+ '%s',
+ decodeEntities( listItem.name )
+ );
+ },
+
+ get currentItemVariationLabel(): string {
+ const { listItem } = getContext< BlockContext >();
+ return listItem ? formatVariationLabel( listItem ) : '';
+ },
+ },
+
+ actions: {
+ *onClickRemove(): AsyncAction< void > {
+ const { listItem, pendingKeys } = getContext< BlockContext >();
+ if ( ! listItem || pendingKeys[ listItem.key ] ) {
+ return;
+ }
+ pendingKeys[ listItem.key ] = true;
+ try {
+ yield shopperListsActions.removeItem(
+ LIST_SLUG,
+ listItem.key
+ );
+ } finally {
+ delete pendingKeys[ listItem.key ];
+ }
+ },
+
+ *onClickMoveToCart(): AsyncAction< void > {
+ const { listItem, pendingKeys } = getContext< BlockContext >();
+ if (
+ ! listItem ||
+ ! listItem.is_purchasable ||
+ pendingKeys[ listItem.key ]
+ ) {
+ return;
+ }
+
+ // Map the schema's `variation` shape to the cart's
+ // SelectedAttributes shape. The schema returns the
+ // slug-form attribute under `raw_attribute` (e.g.
+ // `attribute_pa_color`) plus a display label under
+ // `attribute` (e.g. "Color"); the cart matches by the
+ // slug-form, so override `attribute` with `raw_attribute`.
+ // Same swap mini-cart's `changeQuantity` does. Empty for
+ // simple products.
+ const variation = listItem.variation.map(
+ ( { raw_attribute: rawAttribute, value, attribute } ) => ( {
+ attribute: rawAttribute || attribute,
+ value,
+ } )
+ );
+ const isVariation = listItem.variation_id > 0;
+
+ // `cartActions.addCartItem` catches its own errors and
+ // surfaces them as store notices, so the yield resolves
+ // the same way on success and failure. Snapshot the
+ // matching line's quantity, run the add, then only remove
+ // from the saved list if it actually grew.
+ const lookup = {
+ id: listItem.id,
+ ...( isVariation && { variation } ),
+ };
+ const beforeItem = cartState.findItemInCart( lookup );
+ const beforeQuantity = beforeItem?.quantity ?? 0;
+
+ pendingKeys[ listItem.key ] = true;
+ try {
+ yield cartActions.addCartItem( {
+ id: listItem.id,
+ quantityToAdd: listItem.quantity,
+ type: isVariation ? 'variation' : 'simple',
+ ...( isVariation && { variation } ),
+ } );
+
+ const afterItem = cartState.findItemInCart( lookup );
+ const afterQuantity = afterItem?.quantity ?? 0;
+
+ if ( afterQuantity <= beforeQuantity ) {
+ return;
+ }
+
+ yield shopperListsActions.removeItem(
+ LIST_SLUG,
+ listItem.key
+ );
+ } finally {
+ delete pendingKeys[ listItem.key ];
+ }
+ },
+ },
+
+ callbacks: {
+ // Wrapper-level watcher: flips `hasShownItems` to `true` the
+ // first time the list has any items. Pairs with `state.isEmpty`
+ // to gate the empty message — a new shopper landing on a page
+ // with nothing saved keeps the flag at its SSR-seeded `false`
+ // and never sees the message; once they save an item (or
+ // landed with items) the flag is `true`, so emptying the list
+ // from that point surfaces the message. The flag never flips
+ // back to `false`, which is what gives the "had-items → now-empty"
+ // transition we want during the session.
+ trackShownItems: () => {
+ const ctx = getContext< BlockContext >();
+ const list = getList( LIST_SLUG );
+ if ( list && list.items.length > 0 && ! ctx.hasShownItems ) {
+ ctx.hasShownItems = true;
+ }
+ },
+
+ // Single shared innerHTML-swap callback for any slot whose
+ // content is one of the schema's preformatted HTML fields.
+ // Mirrors the atomic product-elements `updateValue` callback:
+ // the watched element carries `data-wp-context='{"htmlField":"price_html"}'`
+ // (or `"image_html"`), and this callback reads that field
+ // off the row's `listItem` and pastes its sanitized HTML into
+ // `element.ref`. PHP renders the same HTML server-side, so
+ // hydration is a no-op when the row's listItem hasn't changed,
+ // and a clean swap when it has (e.g. after Remove shifts the
+ // next item into this slot).
+ updateInnerHtml: () => {
+ const { ref } = getElement();
+ const { listItem, htmlField } = getContext< BlockContext >();
+ if ( ! ref || ! listItem || ! htmlField ) {
+ return;
+ }
+ const html = listItem[ htmlField ];
+ if ( typeof html === 'string' ) {
+ ref.innerHTML = sanitizeHTML( html, {
+ tags: ALLOWED_TAGS,
+ attr: ALLOWED_ATTR,
+ } );
+ }
+ },
+ },
+ },
+ { lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/index.tsx
new file mode 100644
index 00000000000..21f24c7656a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/index.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+import './style.scss';
+
+registerBlockType( metadata, {
+ icon: {
+ src: (
+ <Icon
+ icon={ starEmpty }
+ className="wc-block-editor-components-block-icon"
+ />
+ ),
+ },
+ edit: Edit,
+ save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
new file mode 100644
index 00000000000..6913e34a3d2
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
@@ -0,0 +1,14 @@
+/**
+ * External dependencies
+ */
+import { InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * The block is rendered server-side, but the inner blocks (default
+ * `core/heading`) must be serialized to `post_content` so user edits
+ * persist — returning `null` would drop them. `<InnerBlocks.Content />`
+ * emits only the inner blocks' static markup.
+ */
+const Save = (): JSX.Element => <InnerBlocks.Content />;
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/style.scss
new file mode 100644
index 00000000000..914cdd49153
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/style.scss
@@ -0,0 +1,45 @@
+// Frontend stylesheet for the Saved for Later block.
+// Layout rules for `.wc-block-components-product-image`,
+// `.wc-block-components-product-price` and `.wc-block-components-product-button`
+// come from the atomic product-image / -price / -button blocks' stylesheets,
+// which we declare as style dependencies in `block.json` so WordPress
+// enqueues them whenever this block renders. Don't duplicate those rules here.
+
+@import "../shopper-lists/item";
+
+// The outer `<section>` is a plain wrapper — no layout of its own. The
+// grid lives on the inner `<ul class="wc-block-saved-for-later__list">`
+// so it doesn't try to grid-place the seeded heading inner block.
+// One concrete `.columns-N` class per supported column count so narrow
+// viewports reflow to fewer columns instead of shrinking items uniformly.
+// Mirrors Product Collection's `__responsive` formula.
+$min-item-width: 150px;
+$grid-gap: var(--wp--style--block-gap, 1.5em);
+
+.wc-block-saved-for-later__list {
+ display: grid;
+ gap: $grid-gap;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ @for $i from 2 through 6 {
+ $gap-count: calc(#{$i} - 1);
+ $total-gap-width: calc(#{$gap-count} * #{$grid-gap});
+ $max-item-width: calc((100% - #{$total-gap-width}) / #{$i});
+
+ &.columns-#{ $i } {
+ grid-template-columns: repeat(
+ auto-fill,
+ minmax(max(#{$min-item-width}, #{$max-item-width}), 1fr)
+ );
+ }
+ }
+}
+
+.wc-block-saved-for-later__empty {
+ color: rgba(0, 0, 0, 0.7);
+ grid-column: 1 / -1;
+ padding: 1em 0;
+ text-align: left;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/shopper-lists/_item.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/shopper-lists/_item.scss
new file mode 100644
index 00000000000..5a61327a83e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/shopper-lists/_item.scss
@@ -0,0 +1,125 @@
+// Row styles shared by every block that renders a shopper-list item card
+// (Saved for Later, Wishlist, …). Consumers `@import` this partial and
+// keep their own layout (grid wrapper, empty-state, header) in their
+// block-local stylesheet.
+
+// Layout rules for `.wc-block-components-product-image`,
+// `.wc-block-components-product-price` and `.wc-block-components-product-button`
+// come from the atomic product-image / -price / -button blocks' stylesheets,
+// which the consuming blocks declare as style dependencies in `block.json`.
+// Don't duplicate those rules here.
+
+.wc-block-shopper-list-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25em;
+
+ // Tombstone rows render `<a>` without an href so iAPI can keep
+ // reconciling them against the live-row template (swapping the
+ // element type would force a re-hydration). The browser already
+ // drops link semantics for hrefless anchors, but make the
+ // non-clickability explicit so any inherited link styles don't
+ // mislead pointer or keyboard users.
+ a:not([href]) {
+ cursor: default;
+ pointer-events: none;
+ }
+
+ // The atomic product-button's `display: flex` outspecifies `[hidden]`,
+ // so the action wrapper (Move to cart / Add to cart) wouldn't honor
+ // `hidden` without this.
+ .wp-block-button.wc-block-components-product-button[hidden] {
+ display: none;
+ }
+
+ // Title spacing/line-height matches Product Collection's default
+ // product-title attributes (level: 2, margin: 0 0 0.75rem, line-height: 1.4).
+ // We target wp-block-post-title (core's post-title class) because the
+ // editor preview mirrors PC's editor render, which uses core/post-title.
+ .wp-block-post-title {
+ line-height: 1.4;
+ margin: 0 0 0.75rem;
+ }
+
+ // Quantity has no atomic equivalent in Product Collection, so we own
+ // its styling. Center-aligned and small to mirror the rest of the row
+ // (price + button use the WP `has-small-font-size` preset). Only the
+ // Saved for Later block renders this element — Wishlist omits it — but
+ // the rule lives here so the row's visual language stays consistent
+ // across consumers.
+ &__quantity {
+ color: rgba(0, 0, 0, 0.7);
+ display: block;
+ font-size: var(--wp--preset--font-size--small, 0.875em);
+ text-align: center;
+ }
+
+ // Variation overlay sits at the bottom of the image and shares the
+ // remove badge's border/background/radius so the two corner overlays
+ // read as a pair. Spans the full image width (with the same 4px inset
+ // the remove badge uses on top/right) and is only rendered for
+ // variation products, so when absent the image isn't pushed around.
+ &__variation {
+ background: #fff;
+ border: 1px solid #43454b;
+ border-radius: 4px;
+ bottom: 4px;
+ box-sizing: border-box;
+ color: #43454b;
+ font-size: var(--wp--preset--font-size--small, 0.875em);
+ left: 4px;
+ padding: 0.25em 0.75em;
+ position: absolute;
+ right: 4px;
+ text-align: center;
+ z-index: 10;
+ }
+
+ // Icon-only Remove button overlaying the image. Mirrors the
+ // `woocommerce-product-gallery__trigger` corner-overlay styles
+ // (white fill, circular, black icon, no border) so the affordance
+ // reads consistently with the gallery zoom trigger.
+ &__remove {
+ appearance: none;
+ background: #fff;
+ border: none;
+ border-radius: 100%;
+ box-sizing: border-box;
+ color: #000;
+ cursor: pointer;
+ display: block;
+ line-height: 0;
+ padding: 0.25em;
+ position: absolute;
+ right: 4px;
+ top: 4px;
+ transition: background-color 120ms ease, color 120ms ease;
+ z-index: 10;
+
+ svg {
+ display: block;
+ fill: currentColor;
+ }
+
+ &:hover:not([disabled]) {
+ background: #000;
+ color: #fff;
+ }
+
+ // Keyboard focus indicator. The default :focus-visible ring would
+ // land on a transparent border against the white badge body and
+ // be invisible, so paint our own — outline (so it sits outside the
+ // border without nudging layout) plus a soft offset to keep it
+ // readable over both the badge body and the product image behind
+ // it. Not applied to :hover-only — pointer users keep the invert.
+ &:focus-visible:not([disabled]) {
+ outline: 2px solid #000;
+ outline-offset: 1px;
+ }
+
+ &[disabled] {
+ cursor: not-allowed;
+ opacity: 0.7;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/block.json
new file mode 100644
index 00000000000..837f2daf0d1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/block.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "woocommerce/wishlist",
+ "version": "1.0.0",
+ "title": "Wishlist",
+ "description": "Display the shopper's wishlist.",
+ "category": "woocommerce",
+ "keywords": [ "WooCommerce", "Wishlist" ],
+ "textdomain": "woocommerce",
+ "attributes": {
+ "columnCount": {
+ "type": "number",
+ "default": 5
+ }
+ },
+ "allowedBlocks": [ "core/heading" ],
+ "supports": {
+ "align": [ "wide", "full" ],
+ "interactivity": true,
+ "html": false,
+ "reusable": false,
+ "spacing": {
+ "margin": true,
+ "padding": true,
+ "blockGap": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ },
+ "color": {
+ "text": true,
+ "background": true
+ }
+ },
+ "viewScriptModule": "woocommerce/wishlist",
+ "style": [
+ "file:../woocommerce/wishlist-style.css",
+ "wc-blocks-style-product-image",
+ "woocommerce-product-price-style",
+ "woocommerce-product-button-style"
+ ]
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
new file mode 100644
index 00000000000..38bc1f98bf6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
@@ -0,0 +1,177 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ useBlockProps,
+ useInnerBlocksProps,
+ InspectorControls,
+} from '@wordpress/block-editor';
+import { PanelBody, RangeControl } from '@wordpress/components';
+import { Icon, trash } from '@wordpress/icons';
+import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
+
+interface WishlistAttributes {
+ columnCount: number;
+}
+
+interface EditProps {
+ attributes: WishlistAttributes;
+ setAttributes: ( attrs: Partial< WishlistAttributes > ) => void;
+}
+
+const MIN_COLUMNS = 2;
+const MAX_COLUMNS = 6;
+
+// Lives in JS because `__()` is needed for the heading copy. `block.json`
+// strings aren't run through translation, so keeping the template here
+// is the only way to ship a localized default.
+const TEMPLATE: [ string, Record< string, unknown > ][] = [
+ [ 'core/heading', { content: __( 'Wishlist', 'woocommerce' ), level: 2 } ],
+];
+
+const PREVIEW_ITEMS = [
+ {
+ key: 'preview-1',
+ name: __( 'Sample product one', 'woocommerce' ),
+ variation: __( 'Size: M', 'woocommerce' ),
+ price: '$19.99',
+ },
+ {
+ key: 'preview-2',
+ name: __( 'Sample product two', 'woocommerce' ),
+ variation: __( 'Color: Blue', 'woocommerce' ),
+ price: '$29.99',
+ },
+ {
+ key: 'preview-3',
+ name: __( 'Sample product three', 'woocommerce' ),
+ variation: '',
+ price: '$9.99',
+ },
+ {
+ key: 'preview-4',
+ name: __( 'Sample product four', 'woocommerce' ),
+ variation: __( 'Size: L', 'woocommerce' ),
+ price: '$24.99',
+ },
+ {
+ key: 'preview-5',
+ name: __( 'Sample product five', 'woocommerce' ),
+ variation: '',
+ price: '$14.99',
+ },
+ {
+ key: 'preview-6',
+ name: __( 'Sample product six', 'woocommerce' ),
+ variation: __( 'Color: Red', 'woocommerce' ),
+ price: '$39.99',
+ },
+];
+
+const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
+ const { columnCount } = attributes;
+
+ const blockProps = useBlockProps( {
+ className: 'wc-block-wishlist',
+ } );
+
+ // `allowedBlocks` is read from block.json automatically — passing it
+ // here would just duplicate the declaration. `templateLock: false`
+ // is the default so we omit that too. The className matches the
+ // `<div>` PHP wraps `$content` in on the frontend, so any CSS keyed
+ // off `__header` applies in both contexts.
+ const innerBlocksProps = useInnerBlocksProps(
+ { className: 'wc-block-wishlist__header' },
+ { template: TEMPLATE }
+ );
+
+ return (
+ <>
+ <InspectorControls>
+ <PanelBody title={ __( 'Settings', 'woocommerce' ) }>
+ <RangeControl
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ label={ __( 'Columns', 'woocommerce' ) }
+ value={ columnCount }
+ onChange={ ( value?: number ) => {
+ if ( typeof value !== 'number' ) {
+ return;
+ }
+ setAttributes( { columnCount: value } );
+ } }
+ min={ MIN_COLUMNS }
+ max={ MAX_COLUMNS }
+ />
+ </PanelBody>
+ </InspectorControls>
+ <section { ...blockProps }>
+ <div { ...innerBlocksProps } />
+ <ul
+ className={ `wc-block-wishlist__list columns-${ columnCount }` }
+ >
+ { PREVIEW_ITEMS.map( ( item ) => (
+ <li
+ key={ item.key }
+ className="wc-block-shopper-list-item"
+ >
+ <div className="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+ <a
+ href="#preview"
+ onClick={ ( e ) => e.preventDefault() }
+ >
+ <img src={ PLACEHOLDER_IMG_SRC } alt="" />
+ </a>
+ <button
+ type="button"
+ className="wc-block-shopper-list-item__remove"
+ aria-label={ sprintf(
+ /* translators: %s: product name. */
+ __(
+ 'Remove %s from wishlist',
+ 'woocommerce'
+ ),
+ item.name
+ ) }
+ disabled
+ >
+ <Icon icon={ trash } size={ 24 } />
+ </button>
+ { item.variation && (
+ <span className="wc-block-shopper-list-item__variation">
+ { item.variation }
+ </span>
+ ) }
+ </div>
+ <h2 className="wp-block-post-title has-text-align-center has-medium-font-size">
+ <a
+ href="#preview"
+ onClick={ ( e ) => e.preventDefault() }
+ >
+ { item.name }
+ </a>
+ </h2>
+ <div className="price wc-block-components-product-price has-text-align-center has-small-font-size">
+ <span className="wc-block-components-product-price__value">
+ { item.price }
+ </span>
+ </div>
+ <div className="wp-block-button wc-block-components-product-button">
+ <button
+ type="button"
+ className="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+ disabled
+ >
+ { __( 'Add to cart', 'woocommerce' ) }
+ </button>
+ </div>
+ </li>
+ ) ) }
+ </ul>
+ </section>
+ </>
+ );
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
new file mode 100644
index 00000000000..a9d6451fcc3
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
@@ -0,0 +1,309 @@
+/**
+ * External dependencies
+ */
+import {
+ getConfig,
+ getContext,
+ getElement,
+ store,
+ type AsyncAction,
+} from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/shopper-lists';
+import '@woocommerce/stores/woocommerce/cart';
+import type {
+ RawShopperListItem,
+ Store as ShopperListsStore,
+} from '@woocommerce/stores/woocommerce/shopper-lists';
+import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import { sanitizeHTML } from '@woocommerce/sanitize';
+
+const universalLock =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const LIST_SLUG = 'wishlist';
+
+type WishlistConfig = {
+ removeLabelTemplate: string;
+};
+
+type BlockContext = {
+ listItem?: RawShopperListItem;
+ htmlField?: 'price_html' | 'image_html';
+ // Item keys currently mid-mutation, used to disable per-row buttons.
+ pendingKeys: Record< string, true >;
+};
+
+type BlockStore = {
+ state: {
+ currentItems: RawShopperListItem[];
+ isCurrentItemPending: boolean;
+ isEmpty: boolean;
+ isAddToCartHidden: boolean;
+ isPriceHidden: boolean;
+ currentItemDisplayName: string;
+ currentItemRemoveLabel: string;
+ currentItemVariationLabel: string;
+ };
+ actions: {
+ onClickRemove: () => Generator< unknown, void >;
+ onClickAddToCart: () => Generator< unknown, void >;
+ };
+ callbacks: {
+ updateInnerHtml: () => void;
+ };
+};
+
+// Allow-list for sanitizing the schema's preformatted strings on innerHTML
+// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
+// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
+// + dimensions + lazy loading).
+const ALLOWED_TAGS = [
+ 'a',
+ 'b',
+ 'em',
+ 'i',
+ 'strong',
+ 'p',
+ 'br',
+ 'span',
+ 'bdi',
+ 'del',
+ 'ins',
+ 'img',
+ 'picture',
+ 'source',
+];
+const ALLOWED_ATTR = [
+ 'class',
+ 'target',
+ 'href',
+ 'rel',
+ 'name',
+ 'download',
+ 'aria-hidden',
+ 'src',
+ 'srcset',
+ 'sizes',
+ 'alt',
+ 'width',
+ 'height',
+ 'loading',
+ 'decoding',
+];
+
+const { state: shopperListsState, actions: shopperListsActions } =
+ store< ShopperListsStore >(
+ 'woocommerce/shopper-lists',
+ {},
+ { lock: universalLock }
+ );
+
+const { state: cartState, actions: cartActions } = store< WooCommerce >(
+ 'woocommerce',
+ {},
+ { lock: universalLock }
+);
+
+const decodeEntities = ( encoded: string ): string => {
+ const txt = document.createElement( 'textarea' );
+ txt.innerHTML = encoded;
+ return txt.value;
+};
+
+const formatVariationLabel = ( item: RawShopperListItem ): string => {
+ if ( ! item.variation || item.variation.length === 0 ) {
+ return '';
+ }
+ return item.variation
+ .map(
+ ( v ) =>
+ `${ decodeEntities( v.attribute ) }: ${ decodeEntities(
+ v.value
+ ) }`
+ )
+ .join( ', ' );
+};
+
+const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;
+
+store< BlockStore >(
+ 'woocommerce/wishlist',
+ {
+ state: {
+ get currentItems(): RawShopperListItem[] {
+ return getList( LIST_SLUG )?.items ?? [];
+ },
+
+ get isCurrentItemPending(): boolean {
+ const { listItem, pendingKeys } = getContext< BlockContext >();
+ return !! listItem && !! pendingKeys[ listItem.key ];
+ },
+
+ // No `hasShownItems` gate: the visitor reached this block
+ // deliberately (My Account endpoint or merchant-placed), so
+ // showing the empty message immediately when the list is
+ // empty is the right signal.
+ get isEmpty(): boolean {
+ const list = getList( LIST_SLUG );
+ if ( ! list ) {
+ return false;
+ }
+ return ! list.isLoading && list.items.length === 0;
+ },
+
+ get isPriceHidden(): boolean {
+ const { listItem } = getContext< BlockContext >();
+ return ! listItem?.price_html;
+ },
+
+ get isAddToCartHidden(): boolean {
+ const { listItem } = getContext< BlockContext >();
+ if ( ! listItem ) {
+ return true;
+ }
+ return ! listItem.is_purchasable;
+ },
+
+ // `data-wp-text` writes its argument as text-content without
+ // running entity decoding, so a name returned by the schema as
+ // `Tom & Jerry` would render literally that way. Bind
+ // templates and SSR text spans to this getter instead of the
+ // raw context field so what the browser shows matches what
+ // PHP wrote on first paint.
+ get currentItemDisplayName(): string {
+ const { listItem } = getContext< BlockContext >();
+ return listItem ? decodeEntities( listItem.name ) : '';
+ },
+
+ get currentItemRemoveLabel(): string {
+ const { listItem } = getContext< BlockContext >();
+ if ( ! listItem ) {
+ return '';
+ }
+ const { removeLabelTemplate } = getConfig(
+ 'woocommerce/wishlist'
+ ) as WishlistConfig;
+ return removeLabelTemplate.replace(
+ '%s',
+ decodeEntities( listItem.name )
+ );
+ },
+
+ get currentItemVariationLabel(): string {
+ const { listItem } = getContext< BlockContext >();
+ return listItem ? formatVariationLabel( listItem ) : '';
+ },
+ },
+
+ actions: {
+ *onClickRemove(): AsyncAction< void > {
+ const { listItem, pendingKeys } = getContext< BlockContext >();
+ if ( ! listItem || pendingKeys[ listItem.key ] ) {
+ return;
+ }
+ pendingKeys[ listItem.key ] = true;
+ try {
+ yield shopperListsActions.removeItem(
+ LIST_SLUG,
+ listItem.key
+ );
+ } finally {
+ delete pendingKeys[ listItem.key ];
+ }
+ },
+
+ *onClickAddToCart(): AsyncAction< void > {
+ const { listItem, pendingKeys } = getContext< BlockContext >();
+ if (
+ ! listItem ||
+ ! listItem.is_purchasable ||
+ pendingKeys[ listItem.key ]
+ ) {
+ return;
+ }
+
+ // Map the schema's `variation` shape to the cart's
+ // SelectedAttributes shape. The schema returns the
+ // slug-form attribute under `raw_attribute` (e.g.
+ // `attribute_pa_color`) plus a display label under
+ // `attribute` (e.g. "Color"); the cart matches by the
+ // slug-form, so override `attribute` with `raw_attribute`.
+ // Empty for simple products.
+ const variation = listItem.variation.map(
+ ( { raw_attribute: rawAttribute, value, attribute } ) => ( {
+ attribute: rawAttribute || attribute,
+ value,
+ } )
+ );
+ const isVariation = listItem.variation_id > 0;
+
+ // Wishlist always adds quantity 1 (no quantity column).
+ // `cartActions.addCartItem` catches its own errors and
+ // surfaces them as store notices, so the yield resolves
+ // the same way on success and failure. Snapshot the
+ // matching line's quantity, run the add, then only remove
+ // from the wishlist if the cart line actually grew — that
+ // guards against partial-stock and silent-failure paths
+ // where we shouldn't drop the wishlist entry.
+ const lookup = {
+ id: listItem.id,
+ ...( isVariation && { variation } ),
+ };
+ const beforeItem = cartState.findItemInCart( lookup );
+ const beforeQuantity = beforeItem?.quantity ?? 0;
+
+ pendingKeys[ listItem.key ] = true;
+ try {
+ yield cartActions.addCartItem( {
+ id: listItem.id,
+ quantityToAdd: 1,
+ type: isVariation ? 'variation' : 'simple',
+ ...( isVariation && { variation } ),
+ } );
+
+ const afterItem = cartState.findItemInCart( lookup );
+ const afterQuantity = afterItem?.quantity ?? 0;
+
+ if ( afterQuantity <= beforeQuantity ) {
+ return;
+ }
+
+ yield shopperListsActions.removeItem(
+ LIST_SLUG,
+ listItem.key
+ );
+ } finally {
+ delete pendingKeys[ listItem.key ];
+ }
+ },
+ },
+
+ callbacks: {
+ // Single shared innerHTML-swap callback for any slot whose
+ // content is one of the schema's preformatted HTML fields.
+ // Mirrors the atomic product-elements `updateValue` callback:
+ // the watched element carries `data-wp-context='{"htmlField":"price_html"}'`
+ // (or `"image_html"`), and this callback reads that field
+ // off the row's `listItem` and pastes its sanitized HTML into
+ // `element.ref`. PHP renders the same HTML server-side, so
+ // hydration is a no-op when the row's listItem hasn't changed,
+ // and a clean swap when it has (e.g. after Remove shifts the
+ // next item into this slot).
+ updateInnerHtml: () => {
+ const { ref } = getElement();
+ const { listItem, htmlField } = getContext< BlockContext >();
+ if ( ! ref || ! listItem || ! htmlField ) {
+ return;
+ }
+ const html = listItem[ htmlField ];
+ if ( typeof html === 'string' ) {
+ ref.innerHTML = sanitizeHTML( html, {
+ tags: ALLOWED_TAGS,
+ attr: ALLOWED_ATTR,
+ } );
+ }
+ },
+ },
+ },
+ { lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/index.tsx
new file mode 100644
index 00000000000..21f24c7656a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/index.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+import './style.scss';
+
+registerBlockType( metadata, {
+ icon: {
+ src: (
+ <Icon
+ icon={ starEmpty }
+ className="wc-block-editor-components-block-icon"
+ />
+ ),
+ },
+ edit: Edit,
+ save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
new file mode 100644
index 00000000000..6913e34a3d2
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
@@ -0,0 +1,14 @@
+/**
+ * External dependencies
+ */
+import { InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * The block is rendered server-side, but the inner blocks (default
+ * `core/heading`) must be serialized to `post_content` so user edits
+ * persist — returning `null` would drop them. `<InnerBlocks.Content />`
+ * emits only the inner blocks' static markup.
+ */
+const Save = (): JSX.Element => <InnerBlocks.Content />;
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/style.scss
new file mode 100644
index 00000000000..9edb9243b73
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/style.scss
@@ -0,0 +1,45 @@
+// Frontend stylesheet for the Wishlist block.
+// Layout rules for `.wc-block-components-product-image`,
+// `.wc-block-components-product-price` and `.wc-block-components-product-button`
+// come from the atomic product-image / -price / -button blocks' stylesheets,
+// which we declare as style dependencies in `block.json` so WordPress
+// enqueues them whenever this block renders. Don't duplicate those rules here.
+
+@import "../shopper-lists/item";
+
+// The outer `<section>` is a plain wrapper — no layout of its own. The
+// grid lives on the inner `<ul class="wc-block-wishlist__list">`
+// so it doesn't try to grid-place the seeded heading inner block.
+// One concrete `.columns-N` class per supported column count so narrow
+// viewports reflow to fewer columns instead of shrinking items uniformly.
+// Mirrors Product Collection's `__responsive` formula.
+$min-item-width: 150px;
+$grid-gap: var(--wp--style--block-gap, 1.5em);
+
+.wc-block-wishlist__list {
+ display: grid;
+ gap: $grid-gap;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ @for $i from 2 through 6 {
+ $gap-count: calc(#{$i} - 1);
+ $total-gap-width: calc(#{$gap-count} * #{$grid-gap});
+ $max-item-width: calc((100% - #{$total-gap-width}) / #{$i});
+
+ &.columns-#{ $i } {
+ grid-template-columns: repeat(
+ auto-fill,
+ minmax(max(#{$min-item-width}, #{$max-item-width}), 1fr)
+ );
+ }
+ }
+}
+
+.wc-block-wishlist__empty {
+ color: rgba(0, 0, 0, 0.7);
+ grid-column: 1 / -1;
+ padding: 1em 0;
+ text-align: left;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
index 6c899ee5b03..a1277513d2f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -21,6 +21,7 @@ import {
type ConfigOf,
type ActionCreatorsOf,
} from '@wordpress/data/build-types/types';
+import { __ } from '@wordpress/i18n';
import { cartStore } from '@woocommerce/block-data';
/**
@@ -160,8 +161,8 @@ export const applyExtensionCartUpdate =
if ( ! includeShipping || ! includeBilling ) {
const {
- shipping_address: _,
- billing_address: __,
+ shipping_address: _shipping,
+ billing_address: _billing,
...responseWithoutAddresses
} = response;
@@ -484,6 +485,58 @@ export const removeItemFromCart =
}
};
+/**
+ * Saves a cart line item to the saved-for-later shopper list.
+ *
+ * On success, emits a `wc-blocks_store_sync_required` event with the saved
+ * item in `detail.item` so a `woocommerce/shopper-lists` iAPI store on the
+ * same page (rendered by a Saved for Later block) can splice the row
+ * into its local state — no extra GET, no race window between a slow
+ * refetch and concurrent mutations. Same envelope the cart's iAPI → wp.data
+ * sync uses to ship payloads (`detail.type === 'from_iAPI'` carries
+ * `quantityChanges`); this is the wp.data → iAPI direction of the same
+ * pattern.
+ *
+ * Removing the item from the cart is the caller's responsibility — keep the
+ * two awaits separate so save and remove errors can be reported distinctly.
+ *
+ * @param {string} cartItemKey Cart item to save.
+ */
+export const saveForLater =
+ ( cartItemKey: string ) => async (): Promise< { key: string } > => {
+ if (
+ typeof cartItemKey !== 'string' ||
+ cartItemKey.trim().length === 0
+ ) {
+ throw new Error(
+ __(
+ 'A cart item is required to save it for later.',
+ 'woocommerce'
+ )
+ );
+ }
+ const { response } = await apiFetchWithHeaders< {
+ response: { key: string };
+ } >( {
+ path: '/wc/store/v1/shopper-lists/saved-for-later/items',
+ method: 'POST',
+ data: { cart_item_key: cartItemKey },
+ cache: 'no-store',
+ } );
+
+ window.dispatchEvent(
+ new CustomEvent( 'wc-blocks_store_sync_required', {
+ detail: {
+ type: 'shopper-list-item-added',
+ slug: 'saved-for-later',
+ item: response,
+ },
+ } )
+ );
+
+ return response;
+ };
+
/**
* Tracks AbortControllers per cart item for cancelling in-flight quantity requests.
*/
@@ -723,6 +776,7 @@ export type Thunks =
| typeof removeCoupon
| typeof addItemToCart
| typeof removeItemFromCart
+ | typeof saveForLater
| typeof changeCartItemQuantity
| typeof selectShippingRate
| typeof updateCustomerData;
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
index 08c36e0742e..e1cbd76034a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
@@ -130,6 +130,9 @@ const preventBatching = [
'/wc/store/v1/checkout',
'/wc/store/v1/checkout?__experimental_calc_totals=true',
'/wc/store/v1/cart/update-item',
+ // Shopper-lists routes don't declare allow_batch yet. Drop these once
+ // the routes opt into batching server-side.
+ '/wc/store/v1/shopper-lists/saved-for-later/items',
];
/**
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index 3eb08fdf1c9..5a0eeee4004 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -46,6 +46,8 @@ const entries = {
'./assets/js/base/stores/store-notices.ts',
'@woocommerce/stores/woocommerce/products':
'./assets/js/base/stores/woocommerce/products.ts',
+ '@woocommerce/stores/woocommerce/shopper-lists':
+ './assets/js/base/stores/woocommerce/shopper-lists.ts',
};
module.exports = {
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-entries.js b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
index e9941ab10a4..35bb14d5260 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-entries.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
@@ -115,6 +115,9 @@ const blocks = {
'reviews-by-product': {
customDir: 'reviews/reviews-by-product',
},
+ 'saved-for-later': {},
+ wishlist: {},
+ 'add-to-wishlist-button': {},
'single-product': {},
'stock-filter': {},
'store-notices': {},
diff --git a/plugins/woocommerce/client/blocks/tsconfig.base.json b/plugins/woocommerce/client/blocks/tsconfig.base.json
index c923c3f976a..d717cc0b033 100644
--- a/plugins/woocommerce/client/blocks/tsconfig.base.json
+++ b/plugins/woocommerce/client/blocks/tsconfig.base.json
@@ -124,6 +124,9 @@
"@woocommerce/stores/woocommerce/products": [
"assets/js/base/stores/woocommerce/products"
],
+ "@woocommerce/stores/woocommerce/shopper-lists": [
+ "assets/js/base/stores/woocommerce/shopper-lists"
+ ],
"@woocommerce/type-defs/*": [
"assets/js/types/type-defs/*"
],
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 2bcbad37cff..02c2cfb6258 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -405,6 +405,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();
// Classes inheriting from RestApiControllerBase.
$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/phpstan.neon b/plugins/woocommerce/phpstan.neon
index cf81175dfb3..2eec6bbede8 100644
--- a/plugins/woocommerce/phpstan.neon
+++ b/plugins/woocommerce/phpstan.neon
@@ -39,6 +39,17 @@ parameters:
WC_TEMPLATE_DEBUG_MODE: bool
ignoreErrors:
- identifier: missingType.iterableValue
+ # ShopperListItemSchema uses ProductItemTrait only for format_variation_data();
+ # the trait's unused prepare_product_price_response() assumes a ProductSchema parent.
+ -
+ message: '#Call to an undefined method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\ShopperListItemSchema::get_tax_display_mode\(\)#'
+ path: src/StoreApi/Utilities/ProductItemTrait.php
+ -
+ message: '#Call to an undefined method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\ShopperListItemSchema::get_price_function_from_tax_display_mode\(\)#'
+ path: src/StoreApi/Utilities/ProductItemTrait.php
+ -
+ message: '#Call to an undefined static method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\AbstractSchema::prepare_product_price_response\(\)#'
+ path: src/StoreApi/Utilities/ProductItemTrait.php
parallel:
maximumNumberOfProcesses: 4
diff --git a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
index 8de3d098441..3d2a968c895 100644
--- a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
@@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Blocks\Assets;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use Exception;
@@ -252,8 +253,9 @@ class AssetDataRegistry {
);
}
- $core_data = $this->get_core_data();
- $core_data['experimentalWcRestApiV4'] = Features::is_enabled( 'rest-api-v4' );
+ $core_data = $this->get_core_data();
+ $core_data['experimentalWcRestApiV4'] = Features::is_enabled( 'rest-api-v4' );
+ $core_data['experimentalCartSaveForLater'] = FeaturesUtil::feature_is_enabled( 'cart_save_for_later' );
// note this WILL wipe any data already registered to these keys because they are protected.
$this->data = array_replace_recursive( $settings, $core_data );
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
new file mode 100644
index 00000000000..cc088fd2707
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
@@ -0,0 +1,277 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+
+/**
+ * Add to Wishlist Button block.
+ *
+ * Single-product trigger UI for the wishlist. Shipped as an inner block of
+ * `woocommerce/add-to-cart-with-options` (ATCWO) via the per-product-type
+ * template parts, so it always renders inside the form's iAPI scope and can
+ * read its `selectedAttributes` context directly. The `ancestor` restriction
+ * in `block.json` prevents merchants from inserting the block outside ATCWO
+ * (where it'd lose iAPI scope and the variation-attribute read would break).
+ *
+ * Hidden for guests and gated by the `product_wishlist` feature flag. On
+ * click, toggles the currently configured product (parent or selected
+ * variation) in the shopper's wishlist via the shared
+ * `woocommerce/shopper-lists` iAPI store. Errors are surfaced through the
+ * page's existing `woocommerce/store-notices` region — no inline notices
+ * area of its own.
+ */
+final class AddToWishlistButton extends AbstractBlock {
+ /**
+ * The list slug this block writes to.
+ */
+ private const LIST_SLUG = 'wishlist';
+
+ /**
+ * Block name.
+ *
+ * @var string
+ */
+ protected $block_name = 'add-to-wishlist-button';
+
+ /**
+ * Render the block.
+ *
+ * @param array $attributes Block attributes.
+ * @param string $content Block content.
+ * @param \WP_Block $block Block instance.
+ * @return string Rendered block type output.
+ */
+ protected function render( $attributes, $content, $block ) {
+ // Guests can't have a wishlist — bail before enqueuing assets or
+ // seeding state.
+ if ( ! is_user_logged_in() ) {
+ return '';
+ }
+
+ $post_id = isset( $block->context['postId'] ) ? absint( $block->context['postId'] ) : 0;
+ if ( ! $post_id ) {
+ return '';
+ }
+
+ $product = wc_get_product( $post_id );
+ if ( ! $product instanceof \WC_Product ) {
+ return '';
+ }
+
+ wp_enqueue_script_module( $this->get_full_block_name() );
+
+ $consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+ BlocksSharedState::load_store_config( $consent );
+
+ $items = $this->prefetch_items();
+
+ // Seed the shared shopper-lists store the same way the Wishlist
+ // block does — restUrl + starter nonce + prefetched items. The
+ // two blocks may both render on the same page (e.g. the merchant
+ // drops the Wishlist block into a sidebar of single-product); iAPI's
+ // deep-merge keeps the first writer's payload, so seeding identical
+ // values here is a no-op when Wishlist already ran.
+ wp_interactivity_state(
+ 'woocommerce/shopper-lists',
+ array(
+ 'restUrl' => get_rest_url(),
+ 'nonce' => wp_create_nonce( 'wc_store_api' ),
+ 'lists' => array(
+ self::LIST_SLUG => array(
+ 'items' => $items,
+ 'isLoading' => false,
+ ),
+ ),
+ )
+ );
+
+ // Visible labels flow through `wp_interactivity_config` so the
+ // JS-side getter can pick the right one based on
+ // `state.isInWishlist`. PHP renders the matching one as the
+ // initial server-side label.
+ wp_interactivity_config(
+ 'woocommerce/add-to-wishlist-button',
+ array(
+ 'addLabel' => $this->get_add_label(),
+ 'savedLabel' => $this->get_saved_label(),
+ 'selectOptionsLabel' => $this->get_select_options_label(),
+ )
+ );
+
+ $is_variable = $product->is_type( 'variable' );
+ $initial_is_in_wishlist = $this->is_initial_in_wishlist( $items, $product );
+ $initial_disabled = $is_variable;
+ $initial_label = $is_variable
+ ? $this->get_select_options_label()
+ : ( $initial_is_in_wishlist ? $this->get_saved_label() : $this->get_add_label() );
+
+ $wrapper_attributes = array(
+ 'class' => 'wc-block-add-to-wishlist-button',
+ 'data-wp-interactive' => 'woocommerce/add-to-wishlist-button',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ 'productId' => $product->get_id(),
+ 'isVariableType' => $is_variable,
+ 'isPending' => false,
+ )
+ ),
+ );
+
+ ob_start();
+ ?>
+ <div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes returns escaped attribute markup. ?>>
+ <button
+ type="button"
+ class="wc-block-add-to-wishlist-button__toggle"
+ data-wp-on--click="actions.onClickToggle"
+ data-wp-bind--aria-pressed="state.isInWishlist"
+ data-wp-bind--disabled="state.isDisabled"
+ <?php echo $initial_is_in_wishlist ? 'aria-pressed="true"' : 'aria-pressed="false"'; ?>
+ <?php
+ if ( $initial_disabled ) {
+ echo 'disabled';
+ }
+ ?>
+ >
+ <span class="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--empty" data-wp-bind--hidden="state.isInWishlist"
+ <?php
+ if ( $initial_is_in_wishlist ) {
+ echo ' hidden';
+ }
+ ?>
+ >
+ <?php echo ShopperListRenderer::get_star_empty_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+ </span>
+ <span class="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--filled" data-wp-bind--hidden="!state.isInWishlist"
+ <?php
+ if ( ! $initial_is_in_wishlist ) {
+ echo ' hidden';
+ }
+ ?>
+ >
+ <?php echo ShopperListRenderer::get_star_filled_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+ </span>
+ <span class="wc-block-add-to-wishlist-button__label" data-wp-text="state.currentLabel"><?php echo esc_html( $initial_label ); ?></span>
+ </button>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Prefetch the wishlist items via `rest_do_request()`. Logged-out
+ * users short-circuit to an empty list — the route requires
+ * authentication and we don't want to fire an API call that's only
+ * going to 401.
+ *
+ * @return array<int, array<string, mixed>> Items in the schema response shape.
+ */
+ private function prefetch_items(): array {
+ if ( ! is_user_logged_in() ) {
+ return array();
+ }
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
+ $response = rest_do_request( $request );
+
+ if ( $response->is_error() ) {
+ $error = $response->as_error();
+ $message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
+ wc_get_logger()->debug(
+ sprintf( 'Add to Wishlist button prefetch failed: %s', $message ),
+ array(
+ 'source' => 'add-to-wishlist-button',
+ 'data' => array( 'slug' => self::LIST_SLUG ),
+ )
+ );
+ return array();
+ }
+
+ $data = $response->get_data();
+ if ( ! is_array( $data ) && ! is_object( $data ) ) {
+ return array();
+ }
+
+ $decoded = json_decode( (string) wp_json_encode( $data ), true );
+ return is_array( $decoded ) ? $decoded : array();
+ }
+
+ /**
+ * Whether the current product (or its parent, for a variable parent
+ * with no selection yet) is already in the prefetched wishlist. For
+ * variable products the SSR star is always empty — we can't know which
+ * variation the shopper will pick before JS hydrates.
+ *
+ * @param array<int, array<string, mixed>> $items Schema-shape items.
+ * @param \WC_Product $product The product being viewed.
+ */
+ private function is_initial_in_wishlist( array $items, \WC_Product $product ): bool {
+ if ( $product->is_type( 'variable' ) ) {
+ return false;
+ }
+ $product_id = $product->get_id();
+ foreach ( $items as $item ) {
+ if ( isset( $item['id'] ) && (int) $item['id'] === $product_id ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Visible label when the product is not in the wishlist.
+ */
+ private function get_add_label(): string {
+ return __( 'Add to wishlist', 'woocommerce' );
+ }
+
+ /**
+ * Visible label when the product is already in the wishlist.
+ */
+ private function get_saved_label(): string {
+ return __( 'Saved to wishlist', 'woocommerce' );
+ }
+
+ /**
+ * Visible label when the shopper still needs to pick variation
+ * attributes before the wishlist toggle can resolve to a specific
+ * variation.
+ */
+ private function get_select_options_label(): string {
+ return __( 'Select options first', 'woocommerce' );
+ }
+
+ /**
+ * Get the frontend script handle for this block type. Scripts are
+ * loaded via `viewScriptModule` in block.json.
+ *
+ * @param string|null $key The key of the script to get.
+ * @return null
+ */
+ protected function get_block_type_script( $key = null ) {
+ return null;
+ }
+
+ /**
+ * Get the frontend style handle for this block type. Returning null
+ * lets WP use the `style` array from block.json.
+ *
+ * @return null
+ */
+ protected function get_block_type_style() {
+ return null;
+ }
+
+ /**
+ * Disable the editor style handle for this block type.
+ *
+ * @return null
+ */
+ protected function get_block_type_editor_style() {
+ return null;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
new file mode 100644
index 00000000000..b0d5eb8750b
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
@@ -0,0 +1,529 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+
+/**
+ * Saved for Later block.
+ *
+ * Renders the shopper's "Saved for Later" list, wired to the `shopper-lists`
+ * Store API endpoints via the shared `woocommerce/shopper-lists` iAPI store.
+ * PHP prefetches the list so the first paint is already populated; JS then
+ * takes over for adds, removes, and Move-to-cart.
+ *
+ * The row markup (image, name, price, remove badge, variation overlay) is
+ * shared with other shopper-list blocks via `ShopperListRenderer`. This
+ * class composes those fragments and adds the bits that are unique to
+ * Saved for Later: auto-injection via the Block Hooks API, the
+ * `hasShownItems` empty-state gating, the per-row quantity span, and the
+ * Move-to-cart action button.
+ */
+final class SavedForLater extends AbstractBlock {
+ /**
+ * The list slug this block renders. Constant — when additional list
+ * types ship as their own blocks (e.g. Wishlist), each one will
+ * hardcode its own slug.
+ */
+ private const LIST_SLUG = 'saved-for-later';
+
+ /**
+ * Block name.
+ *
+ * @var string
+ */
+ protected $block_name = 'saved-for-later';
+
+ /**
+ * Initialize this block type.
+ */
+ protected function initialize(): void {
+ parent::initialize();
+
+ // We do not use `BlockHooksTrait` currently as it has issues with PHPStan.
+ add_filter( 'hooked_block_types', array( $this, 'register_hooked_block' ), 9, 4 );
+ add_filter( 'hooked_block_woocommerce/saved-for-later', array( $this, 'set_hooked_block_attributes' ), 10, 4 );
+ }
+
+ /**
+ * Auto-inject this block after `woocommerce/cart`, scoped to the cart page.
+ *
+ * @param array $hooked_block_types Block names hooked at this position.
+ * @param string $relative_position Position of the insertion point.
+ * @param string $anchor_block_type Anchor block name.
+ * @param array|\WP_Post|\WP_Block_Template|null $context Where the block is being embedded.
+ * @return array
+ */
+ public function register_hooked_block( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
+ if ( 'after' !== $relative_position || 'woocommerce/cart' !== $anchor_block_type ) {
+ return $hooked_block_types;
+ }
+
+ // `wc_get_page_id()` returns -1 when the page option isn't set.
+ $cart_page_id = (int) wc_get_page_id( 'cart' );
+ if ( $cart_page_id <= 0 || ! ( $context instanceof \WP_Post ) || (int) $context->ID !== $cart_page_id ) {
+ return $hooked_block_types;
+ }
+
+ // Don't double-inject if the block is already in the cart page
+ // content.
+ if ( has_block( $this->get_full_block_name(), $context ) ) {
+ return $hooked_block_types;
+ }
+
+ $hooked_block_types[] = $this->get_full_block_name();
+ return $hooked_block_types;
+ }
+
+ /**
+ * Seed a default heading inner block on the auto-injected block.
+ *
+ * @param array|null $parsed_hooked_block The parsed hooked block array, or null to suppress insertion.
+ * @param string $hooked_block_type The hooked block type name.
+ * @param string $relative_position Position of the insertion point.
+ * @param array $parsed_anchor_block The anchor block, in parsed block array format.
+ * @return array|null
+ */
+ public function set_hooked_block_attributes( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block ) {
+ if ( null === $parsed_hooked_block || 'after' !== $relative_position ) {
+ return $parsed_hooked_block;
+ }
+ if ( ! isset( $parsed_anchor_block['blockName'] ) || 'woocommerce/cart' !== $parsed_anchor_block['blockName'] ) {
+ return $parsed_hooked_block;
+ }
+
+ // Seed a `core/heading` inner block so freshly-injected instances
+ // ship with the same heading the editor template seeds. We append
+ // unconditionally — extensions are free to hook
+ // `hooked_block_woocommerce/saved-for-later` to add their own
+ // inner blocks, and gating on `empty( innerBlocks )` would silently
+ // suppress our heading whenever any other extension ran first.
+ //
+ // `core/heading` is a static block, so the serialised markup must
+ // match what the editor would have saved (`<h2 class="wp-block-heading">…</h2>`)
+ // or it'll fail block validation when the cart page is opened in the
+ // editor. `attrs.content` mirrors what the editor's template seeds
+ // (`{ content, level }`) so the parsed shape round-trips identically;
+ // the value is the raw string because attrs are JSON-encoded into the
+ // block comment and `esc_html()` would corrupt translations whose text
+ // contains `&`, `<`, etc. The matching `null` push onto `innerContent`
+ // is what makes `WP_Block::render()` walk into the heading when
+ // building `$content`.
+ $list_heading = __( 'Saved for later', 'woocommerce' );
+ $heading_html = '<h2 class="wp-block-heading">' . esc_html( $list_heading ) . '</h2>';
+
+ if ( ! isset( $parsed_hooked_block['innerBlocks'] ) || ! is_array( $parsed_hooked_block['innerBlocks'] ) ) {
+ $parsed_hooked_block['innerBlocks'] = array();
+ }
+ $parsed_hooked_block['innerBlocks'][] = array(
+ 'blockName' => 'core/heading',
+ 'attrs' => array(
+ 'level' => 2,
+ 'content' => $list_heading,
+ ),
+ 'innerBlocks' => array(),
+ 'innerHTML' => $heading_html,
+ 'innerContent' => array( $heading_html ),
+ );
+ if ( ! isset( $parsed_hooked_block['innerContent'] ) || ! is_array( $parsed_hooked_block['innerContent'] ) ) {
+ $parsed_hooked_block['innerContent'] = array();
+ }
+ $parsed_hooked_block['innerContent'][] = null;
+
+ return $parsed_hooked_block;
+ }
+
+ /**
+ * Render the block.
+ *
+ * @param array $attributes Block attributes.
+ * @param string $content Block content.
+ * @param \WP_Block $block Block instance.
+ * @return string Rendered block type output.
+ */
+ protected function render( $attributes, $content, $block ) {
+ // Guests have no personal list — bail before enqueuing assets or seeding state.
+ if ( ! is_user_logged_in() ) {
+ return '';
+ }
+
+ // Set from render() (not Cart::enqueue_data via has_block()) so it works when this
+ // block is auto-injected via the Block Hooks API and isn't in stored post_content.
+ if ( wc_get_container()->get( LegacyProxy::class )->call_function( 'is_cart' ) ) {
+ $this->asset_data_registry->add( 'cartPageHasSavedForLater', true );
+ }
+
+ // Clamp to the 2-6 range the SCSS `@for $i from 2 through 6` loop and
+ // the editor `RangeControl` both support. `absint()` first defends
+ // against a code-editor override (the attribute can be set to any
+ // JSON value there); the `min`/`max` then keep the value within the
+ // range where a `&.columns-#{$i}` rule actually exists.
+ $column_count = min( 6, max( 2, absint( $attributes['columnCount'] ?? 5 ) ) );
+
+ wp_enqueue_script_module( $this->get_full_block_name() );
+
+ $consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+ BlocksSharedState::load_store_config( $consent );
+ BlocksSharedState::load_placeholder_image( $consent );
+ // `Move to cart` calls into the shared cart store, which expects
+ // `state.cart.items` and friends. Without this load the cart store
+ // would have no hydrated cart and the action would throw on the
+ // first click.
+ BlocksSharedState::load_cart_state( $consent );
+
+ $items = $this->prefetch_items();
+
+ // Seed the shared shopper-lists store with the rest URL, the
+ // pre-fetched items, and a starter nonce. The starter nonce is
+ // what the cart store also seeds via `state.nonce` — the JS layer
+ // keeps it fresh by reading the `Nonce` response header on every
+ // subsequent request, so this is just the bootstrap value (and
+ // avoids deadlocking mutations that await `isNonceReady` before
+ // any GET has fired).
+ wp_interactivity_state(
+ 'woocommerce/shopper-lists',
+ array(
+ 'restUrl' => get_rest_url(),
+ 'nonce' => wp_create_nonce( 'wc_store_api' ),
+ 'lists' => array(
+ self::LIST_SLUG => array(
+ 'items' => $items,
+ 'isLoading' => false,
+ ),
+ ),
+ )
+ );
+
+ // Templates flow through `wp_interactivity_config` so the JS-side
+ // getters can interpolate them (`%d`, `%s`). Visible strings (empty
+ // state, error, action label) are rendered server-side and toggled
+ // with directives, so they don't need to ride here too.
+ wp_interactivity_config(
+ 'woocommerce/saved-for-later',
+ array(
+ 'quantityLabelTemplate' => $this->get_quantity_label_template(),
+ 'removeLabelTemplate' => $this->get_remove_label_template(),
+ )
+ );
+
+ // `hasShownItems` seeds the per-block context so the empty message
+ // stays hidden for new shoppers who land on a page with nothing
+ // saved. The JS-side watcher flips it to `true` the first time the
+ // list has any items (whether that's the SSR seed or a runtime add
+ // via "Save for later"), and `state.isEmpty` only flips on when the
+ // flag is set *and* the list is currently empty. The flag lives in
+ // the per-block context, so it naturally resets on every full page
+ // load — no extra Store API field or persisted flag needed.
+ // `data-wp-context---notices` seeds the store-notices namespace
+ // alongside the block's own context on the same wrapper.
+ $wrapper_attributes = array(
+ 'class' => 'wc-block-saved-for-later',
+ 'data-wp-interactive' => 'woocommerce/saved-for-later',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ 'hasShownItems' => ! empty( $items ),
+ // `stdClass` so it serialises as `{}`, not `[]` —
+ // iAPI's reactive proxy only fires updates on object
+ // writes, not array expandos.
+ 'pendingKeys' => new \stdClass(),
+ )
+ ),
+ 'data-wp-context---notices' => 'woocommerce/store-notices::' . (string) wp_json_encode( array( 'notices' => array() ) ),
+ 'data-wp-watch' => 'callbacks.trackShownItems',
+ );
+
+ $list_class = sprintf( 'wc-block-saved-for-later__list columns-%d', $column_count );
+
+ $ul_inner = $this->render_template_markup() . $this->render_items_markup( $items ) . $this->render_empty_markup();
+ $before_list = $this->render_header_markup( $content, empty( $items ) ) . ShopperListRenderer::render_interactivity_notices_region( 'wc-block-saved-for-later__notices' );
+
+ return ShopperListRenderer::render_grid_wrapper( $wrapper_attributes, $list_class, $ul_inner, $before_list );
+ }
+
+ /**
+ * Prefetch the saved-for-later items via `rest_do_request()`. Logged-out
+ * users short-circuit to an empty list — the route requires authentication
+ * and we don't want to fire an API call that's only going to 401.
+ *
+ * @return array<int, array<string, mixed>> Items in the schema response shape.
+ */
+ private function prefetch_items(): array {
+ if ( ! is_user_logged_in() ) {
+ return array();
+ }
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
+ $response = rest_do_request( $request );
+
+ if ( $response->is_error() ) {
+ $error = $response->as_error();
+ $message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
+ // Logged at debug level on purpose: prefetch failures are
+ // often transient (network blips, auth refresh races) and
+ // the user-visible behaviour is the empty state — nothing
+ // for ops to act on. Anyone investigating a regression can
+ // flip the WC logger to debug to surface them.
+ wc_get_logger()->debug(
+ sprintf( 'Saved for Later prefetch failed: %s', $message ),
+ array(
+ 'source' => 'saved-for-later',
+ 'data' => array( 'slug' => self::LIST_SLUG ),
+ )
+ );
+ return array();
+ }
+
+ $data = $response->get_data();
+ if ( ! is_array( $data ) && ! is_object( $data ) ) {
+ return array();
+ }
+
+ // The schema casts `prices` and image entries to stdClass so the
+ // JSON response renders objects, not arrays. Round-trip through
+ // JSON encode/decode to normalise everything to nested arrays so
+ // the SSR markup helpers below can treat fields uniformly.
+ $decoded = json_decode( (string) wp_json_encode( $data ), true );
+ return is_array( $decoded ) ? $decoded : array();
+ }
+
+ /**
+ * The `<template data-wp-each>` describing how each item is rendered on
+ * the client. Pre-rendered children sit alongside as `data-wp-each-child`
+ * elements so first paint is populated. Composes the shared row markup
+ * with Saved for Later's quantity span and Move-to-cart action button.
+ *
+ * @return string
+ */
+ private function render_template_markup(): string {
+ $row_inner = ShopperListRenderer::render_template_common_row()
+ . $this->render_template_quantity()
+ . $this->render_template_move_to_cart();
+ return ShopperListRenderer::render_each_template( $row_inner );
+ }
+
+ /**
+ * Render the SSR markup for each item. JS will reconcile these via
+ * `data-wp-each-child` after hydration.
+ *
+ * @param array<int, array<string, mixed>> $items Schema-shape items.
+ * @return string
+ */
+ private function render_items_markup( array $items ): string {
+ $markup = '';
+ foreach ( $items as $item ) {
+ $markup .= $this->render_item_markup( $item );
+ }
+ return $markup;
+ }
+
+ /**
+ * Render a single SSR item. Composes the shared image / name / price
+ * markup with the SFL-specific quantity span and Move-to-cart button.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @return string
+ */
+ private function render_item_markup( array $item ): string {
+ $row_inner = ShopperListRenderer::render_ssr_common_row( $item, $this->get_remove_label_template() )
+ . $this->render_ssr_quantity( $item )
+ . $this->render_ssr_move_to_cart( $item );
+ return ShopperListRenderer::render_each_child( $item, $row_inner );
+ }
+
+ /**
+ * Template-mode markup for the quantity span. SFL-specific — Wishlist
+ * has no quantity column.
+ *
+ * @return string
+ */
+ private function render_template_quantity(): string {
+ return sprintf(
+ '<span class="%s__quantity" data-wp-text="state.currentItemQuantityLabel"></span>',
+ esc_attr( ShopperListRenderer::ROW_CLASS )
+ );
+ }
+
+ /**
+ * Template-mode markup for the Move-to-cart action button. SFL-specific.
+ *
+ * @return string
+ */
+ private function render_template_move_to_cart(): string {
+ ob_start();
+ ?>
+ <div class="wp-block-button wc-block-components-product-button" data-wp-bind--hidden="state.isMoveToCartHidden">
+ <button
+ type="button"
+ class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+ data-wp-on--click="actions.onClickMoveToCart"
+ data-wp-bind--disabled="state.isCurrentItemPending"
+ >
+ <?php echo esc_html( $this->get_move_to_cart_label() ); ?>
+ </button>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * SSR-mode markup for the quantity span. SFL-specific.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @return string
+ */
+ private function render_ssr_quantity( array $item ): string {
+ $quantity = (int) ( $item['quantity'] ?? 1 );
+ $quantity_label = sprintf( $this->get_quantity_label_template(), $quantity );
+ return sprintf(
+ '<span class="%s__quantity">%s</span>',
+ esc_attr( ShopperListRenderer::ROW_CLASS ),
+ esc_html( $quantity_label )
+ );
+ }
+
+ /**
+ * SSR-mode markup for the Move-to-cart action button. SFL-specific.
+ * Always emits the wrapper so iAPI can toggle `hidden` after hydration
+ * without swapping the row out. Starts hidden when the row isn't
+ * purchasable.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @return string
+ */
+ private function render_ssr_move_to_cart( array $item ): string {
+ $is_move_to_cart_hidden = empty( $item['is_purchasable'] );
+ ob_start();
+ ?>
+ <div
+ class="wp-block-button wc-block-components-product-button"
+ data-wp-bind--hidden="state.isMoveToCartHidden"
+ <?php
+ if ( $is_move_to_cart_hidden ) {
+ echo 'hidden';
+ }
+ ?>
+ >
+ <button
+ type="button"
+ class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+ data-wp-on--click="actions.onClickMoveToCart"
+ data-wp-bind--disabled="state.isCurrentItemPending"
+ >
+ <?php echo esc_html( $this->get_move_to_cart_label() ); ?>
+ </button>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Wrap the inner-block content (heading + any future siblings) in an
+ * element whose visibility mirrors the empty-state gating: hidden when
+ * the shopper has never seen items in this session, revealed once
+ * `context.hasShownItems` flips to `true`. Returns an empty string when
+ * there's no content to wrap (e.g. merchant deleted the heading and
+ * saved), so we don't emit an empty `<div>`.
+ *
+ * @param string $content Rendered inner-block content (typically the heading HTML).
+ * @param bool $is_empty Whether the saved-for-later list is empty on initial paint.
+ * @return string
+ */
+ private function render_header_markup( string $content, bool $is_empty ): string {
+ if ( '' === $content ) {
+ return '';
+ }
+ $hidden_attr = $is_empty ? ' hidden' : '';
+ return sprintf(
+ '<div class="wc-block-saved-for-later__header" data-wp-bind--hidden="!context.hasShownItems"%s>%s</div>',
+ $hidden_attr,
+ $content
+ );
+ }
+
+ /**
+ * Render the empty-state markup. Always present in the DOM so JS can
+ * toggle it on once the last item is removed. Initially hidden: SSR
+ * never shows the message, since `state.isEmpty` requires the JS-side
+ * `hasShownItems` context flag to flip first.
+ *
+ * @return string
+ */
+ private function render_empty_markup(): string {
+ return ShopperListRenderer::render_empty_state(
+ __( 'Nothing saved yet — items you save from the cart will appear here.', 'woocommerce' ),
+ 'wc-block-saved-for-later__empty',
+ true
+ );
+ }
+
+ /**
+ * Sprintf template for the per-row quantity label. Used both by PHP SSR
+ * (`render_ssr_quantity()`) and by the JS-side getter (via
+ * `wp_interactivity_config`) so both paths produce the same string after
+ * `%d` interpolation.
+ */
+ private function get_quantity_label_template(): string {
+ /* translators: %d: quantity of saved items. */
+ return __( 'Quantity: %d', 'woocommerce' );
+ }
+
+ /**
+ * Sprintf template for the per-row remove button's aria-label. Same dual
+ * use as the quantity template.
+ */
+ private function get_remove_label_template(): string {
+ /* translators: %s: product name. */
+ return __( 'Remove %s from Saved for later list', 'woocommerce' );
+ }
+
+ /**
+ * Visible label for the move-to-cart action button, used by both the
+ * iAPI `<template>` and the SSR per-row markup.
+ */
+ private function get_move_to_cart_label(): string {
+ return __( 'Move to cart', 'woocommerce' );
+ }
+
+ /**
+ * Get the frontend script handle for this block type.
+ *
+ * Scripts are loaded via `viewScriptModule` in block.json.
+ *
+ * @param string|null $key The key of the script to get.
+ * @return null
+ */
+ protected function get_block_type_script( $key = null ) {
+ return null;
+ }
+
+ /**
+ * Get the frontend style handle for this block type.
+ *
+ * Returning null lets WP use the `style` array from block.json, which
+ * lists this block's own stylesheet plus the atomic
+ * product-image / product-price / product-button stylesheets we
+ * borrow class names from. We can't render those atomic blocks as
+ * inner blocks (they rely on WP_Query / $post loop context, which
+ * this block doesn't have — it hydrates from a Store API call), so
+ * declaring them as style dependencies is the only way to get WP
+ * to enqueue their CSS whenever Saved for Later renders.
+ *
+ * @return null
+ */
+ protected function get_block_type_style() {
+ return null;
+ }
+
+ /**
+ * Disable the editor style handle for this block type.
+ *
+ * @return null
+ */
+ protected function get_block_type_editor_style() {
+ return null;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php b/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
new file mode 100644
index 00000000000..1a26fb39d39
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
@@ -0,0 +1,370 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+
+/**
+ * Wishlist block.
+ *
+ * Renders the shopper's wishlist, wired to the `shopper-lists` Store API
+ * endpoints via the shared `woocommerce/shopper-lists` iAPI store. PHP
+ * prefetches the list so the first paint is already populated; JS then
+ * takes over for adds, removes, and the per-row "Add to cart" action.
+ *
+ * Unlike Saved for Later, this block is merchant-placed — no Block Hooks
+ * API integration. It's rendered by the `/my-account/wishlist/` endpoint
+ * (gated by the `product_wishlist` feature flag) and can also be placed
+ * on any other page or template. "Add to cart" mirrors Saved for Later's
+ * Move-to-cart flow: add the product to the cart, then remove it from the
+ * wishlist on confirmed success.
+ */
+final class Wishlist extends AbstractBlock {
+ /**
+ * The list slug this block renders. Constant — when additional list
+ * types ship as their own blocks, each one hardcodes its own slug.
+ */
+ private const LIST_SLUG = 'wishlist';
+
+ /**
+ * Block name.
+ *
+ * @var string
+ */
+ protected $block_name = 'wishlist';
+
+ /**
+ * Render the block.
+ *
+ * @param array $attributes Block attributes.
+ * @param string $content Block content.
+ * @param \WP_Block $block Block instance.
+ * @return string Rendered block type output.
+ */
+ protected function render( $attributes, $content, $block ) {
+ // Guests have no personal list — bail before enqueuing assets or
+ // seeding state. The My Account endpoint isn't reachable for
+ // guests, but the block can also be placed by a merchant on any
+ // page, where this guard is what stops it from rendering an
+ // empty shell for logged-out visitors.
+ if ( ! is_user_logged_in() ) {
+ return '';
+ }
+
+ // Clamp to the 2-6 range the SCSS `@for $i from 2 through 6` loop
+ // and the editor `RangeControl` both support. `absint()` first
+ // defends against a code-editor override (the attribute can be set
+ // to any JSON value there); the `min`/`max` then keep the value
+ // within the range where a `&.columns-#{$i}` rule actually exists.
+ $column_count = min( 6, max( 2, absint( $attributes['columnCount'] ?? 5 ) ) );
+
+ wp_enqueue_script_module( $this->get_full_block_name() );
+
+ $consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+ BlocksSharedState::load_store_config( $consent );
+ BlocksSharedState::load_placeholder_image( $consent );
+ // `Add to cart` calls into the shared cart store, which expects
+ // `state.cart.items` and friends. Without this load the cart store
+ // would have no hydrated cart and the action would throw on the
+ // first click.
+ BlocksSharedState::load_cart_state( $consent );
+
+ $items = $this->prefetch_items();
+
+ // Seed the shared shopper-lists store with the rest URL, the
+ // pre-fetched items, and a starter nonce. The starter nonce is
+ // what the cart store also seeds via `state.nonce` — the JS layer
+ // keeps it fresh by reading the `Nonce` response header on every
+ // subsequent request, so this is just the bootstrap value (and
+ // avoids deadlocking mutations that await `isNonceReady` before
+ // any GET has fired).
+ wp_interactivity_state(
+ 'woocommerce/shopper-lists',
+ array(
+ 'restUrl' => get_rest_url(),
+ 'nonce' => wp_create_nonce( 'wc_store_api' ),
+ 'lists' => array(
+ self::LIST_SLUG => array(
+ 'items' => $items,
+ 'isLoading' => false,
+ ),
+ ),
+ )
+ );
+
+ // Only the remove-button aria-label template needs JS-side
+ // interpolation; visible strings (empty state, action label) are
+ // rendered server-side and toggled with directives.
+ wp_interactivity_config(
+ 'woocommerce/wishlist',
+ array(
+ 'removeLabelTemplate' => $this->get_remove_label_template(),
+ )
+ );
+
+ // No `hasShownItems` flag: unlike Saved for Later (which auto-
+ // renders on every cart visit and must avoid flashing an empty
+ // message before a runtime save lands), Wishlist is reached
+ // deliberately — by the My Account endpoint or because a merchant
+ // placed it. Showing the empty message immediately is the right
+ // signal: the visitor came to look at their wishlist, and it's
+ // empty. `data-wp-context---notices` seeds the store-notices
+ // namespace alongside the block's own context on the same wrapper.
+ $wrapper_attributes = array(
+ 'class' => 'wc-block-wishlist',
+ 'data-wp-interactive' => 'woocommerce/wishlist',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ // `stdClass` so it serialises as `{}`, not `[]` —
+ // iAPI's reactive proxy only fires updates on object
+ // writes, not array expandos.
+ 'pendingKeys' => new \stdClass(),
+ )
+ ),
+ 'data-wp-context---notices' => 'woocommerce/store-notices::' . (string) wp_json_encode( array( 'notices' => array() ) ),
+ );
+
+ $list_class = sprintf( 'wc-block-wishlist__list columns-%d', $column_count );
+ $ul_inner = $this->render_template_markup() . $this->render_items_markup( $items ) . $this->render_empty_markup( $items );
+ $before_list = $this->render_header_markup( $content ) . ShopperListRenderer::render_interactivity_notices_region( 'wc-block-wishlist__notices' );
+
+ return ShopperListRenderer::render_grid_wrapper( $wrapper_attributes, $list_class, $ul_inner, $before_list );
+ }
+
+ /**
+ * Prefetch the wishlist items via `rest_do_request()`. Logged-out
+ * users short-circuit to an empty list — the route requires
+ * authentication and we don't want to fire an API call that's only
+ * going to 401.
+ *
+ * @return array<int, array<string, mixed>> Items in the schema response shape.
+ */
+ private function prefetch_items(): array {
+ if ( ! is_user_logged_in() ) {
+ return array();
+ }
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
+ $response = rest_do_request( $request );
+
+ if ( $response->is_error() ) {
+ $error = $response->as_error();
+ $message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
+ // Logged at debug level on purpose: prefetch failures are
+ // often transient (network blips, auth refresh races) and
+ // the user-visible behaviour is the empty state — nothing
+ // for ops to act on.
+ wc_get_logger()->debug(
+ sprintf( 'Wishlist prefetch failed: %s', $message ),
+ array(
+ 'source' => 'wishlist',
+ 'data' => array( 'slug' => self::LIST_SLUG ),
+ )
+ );
+ return array();
+ }
+
+ $data = $response->get_data();
+ if ( ! is_array( $data ) && ! is_object( $data ) ) {
+ return array();
+ }
+
+ // The schema casts `prices` and image entries to stdClass so the
+ // JSON response renders objects, not arrays. Round-trip through
+ // JSON encode/decode to normalise everything to nested arrays so
+ // the SSR markup helpers can treat fields uniformly.
+ $decoded = json_decode( (string) wp_json_encode( $data ), true );
+ return is_array( $decoded ) ? $decoded : array();
+ }
+
+ /**
+ * The `<template data-wp-each>` describing how each item is rendered
+ * on the client. Pre-rendered children sit alongside as
+ * `data-wp-each-child` elements so first paint is populated. Composes
+ * the shared row markup with the Wishlist-specific "Add to cart"
+ * action button.
+ *
+ * @return string
+ */
+ private function render_template_markup(): string {
+ $row_inner = ShopperListRenderer::render_template_common_row()
+ . $this->render_template_add_to_cart();
+ return ShopperListRenderer::render_each_template( $row_inner );
+ }
+
+ /**
+ * Render the SSR markup for each item. JS will reconcile these via
+ * `data-wp-each-child` after hydration.
+ *
+ * @param array<int, array<string, mixed>> $items Schema-shape items.
+ * @return string
+ */
+ private function render_items_markup( array $items ): string {
+ $markup = '';
+ foreach ( $items as $item ) {
+ $markup .= $this->render_item_markup( $item );
+ }
+ return $markup;
+ }
+
+ /**
+ * Render a single SSR item. Composes the shared image / name / price
+ * markup with the Wishlist-specific "Add to cart" button.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @return string
+ */
+ private function render_item_markup( array $item ): string {
+ $row_inner = ShopperListRenderer::render_ssr_common_row( $item, $this->get_remove_label_template() )
+ . $this->render_ssr_add_to_cart( $item );
+ return ShopperListRenderer::render_each_child( $item, $row_inner );
+ }
+
+ /**
+ * Template-mode markup for the "Add to cart" action button. iAPI
+ * substitutes the per-row state through `data-wp-bind--hidden` and
+ * `data-wp-bind--disabled`.
+ *
+ * @return string
+ */
+ private function render_template_add_to_cart(): string {
+ ob_start();
+ ?>
+ <div class="wp-block-button wc-block-components-product-button" data-wp-bind--hidden="state.isAddToCartHidden">
+ <button
+ type="button"
+ class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+ data-wp-on--click="actions.onClickAddToCart"
+ data-wp-bind--disabled="state.isCurrentItemPending"
+ >
+ <?php echo esc_html( $this->get_add_to_cart_label() ); ?>
+ </button>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * SSR-mode markup for the "Add to cart" action button. Always emits
+ * the wrapper so iAPI can toggle `hidden` after hydration without
+ * swapping the row out. Starts hidden when the row isn't purchasable.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @return string
+ */
+ private function render_ssr_add_to_cart( array $item ): string {
+ $is_hidden = empty( $item['is_purchasable'] );
+ ob_start();
+ ?>
+ <div
+ class="wp-block-button wc-block-components-product-button"
+ data-wp-bind--hidden="state.isAddToCartHidden"
+ <?php
+ if ( $is_hidden ) {
+ echo 'hidden';
+ }
+ ?>
+ >
+ <button
+ type="button"
+ class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+ data-wp-on--click="actions.onClickAddToCart"
+ data-wp-bind--disabled="state.isCurrentItemPending"
+ >
+ <?php echo esc_html( $this->get_add_to_cart_label() ); ?>
+ </button>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Wrap the inner-block content (heading + any future siblings) in a
+ * div. Unlike Saved for Later, no `hasShownItems` gating — the header
+ * is always shown when there's content for it. Returns an empty
+ * string when there's no content to wrap, so we don't emit an empty
+ * `<div>`.
+ *
+ * @param string $content Rendered inner-block content (typically the heading HTML).
+ * @return string
+ */
+ private function render_header_markup( string $content ): string {
+ if ( '' === $content ) {
+ return '';
+ }
+ return '<div class="wc-block-wishlist__header">' . $content . '</div>';
+ }
+
+ /**
+ * Render the empty-state markup. Visible on first paint when the
+ * list is empty (no `hasShownItems` gate), then iAPI takes over via
+ * `state.isEmpty` for runtime transitions.
+ *
+ * @param array<int, array<string, mixed>> $items Schema-shape items.
+ * @return string
+ */
+ private function render_empty_markup( array $items ): string {
+ return ShopperListRenderer::render_empty_state(
+ __( 'Your wishlist is empty. Items you add to your wishlist will appear here.', 'woocommerce' ),
+ 'wc-block-wishlist__empty',
+ ! empty( $items )
+ );
+ }
+
+ /**
+ * Sprintf template for the per-row remove button's aria-label. Used
+ * both by PHP SSR and by the JS-side getter (via
+ * `wp_interactivity_config`) so both paths produce the same string
+ * after `%s` interpolation.
+ */
+ private function get_remove_label_template(): string {
+ /* translators: %s: product name. */
+ return __( 'Remove %s from wishlist', 'woocommerce' );
+ }
+
+ /**
+ * Visible label for the add-to-cart action button, used by both the
+ * iAPI `<template>` and the SSR per-row markup.
+ */
+ private function get_add_to_cart_label(): string {
+ return __( 'Add to cart', 'woocommerce' );
+ }
+
+ /**
+ * Get the frontend script handle for this block type.
+ *
+ * Scripts are loaded via `viewScriptModule` in block.json.
+ *
+ * @param string|null $key The key of the script to get.
+ * @return null
+ */
+ protected function get_block_type_script( $key = null ) {
+ return null;
+ }
+
+ /**
+ * Get the frontend style handle for this block type.
+ *
+ * Returning null lets WP use the `style` array from block.json, which
+ * lists this block's own stylesheet plus the atomic
+ * product-image / product-price / product-button stylesheets we
+ * borrow class names from.
+ *
+ * @return null
+ */
+ protected function get_block_type_style() {
+ return null;
+ }
+
+ /**
+ * Disable the editor style handle for this block type.
+ *
+ * @return null
+ */
+ protected function get_block_type_editor_style() {
+ return null;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index 046a9111098..ba0c6f381de 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
use Automattic\WooCommerce\Blocks\BlockTypes\Cart;
use Automattic\WooCommerce\Blocks\BlockTypes\Checkout;
use Automattic\WooCommerce\Blocks\BlockTypes\MiniCartContents;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
/**
* BlockTypesController class.
@@ -555,6 +556,15 @@ final class BlockTypesController {
MiniCartContents::get_mini_cart_block_types()
);
+ if ( wc_get_container()->get( ShopperListsController::class )->is_enabled( 'saved-for-later' ) ) {
+ $block_types[] = 'SavedForLater';
+ }
+
+ if ( wc_get_container()->get( ShopperListsController::class )->is_enabled( 'wishlist' ) ) {
+ $block_types[] = 'Wishlist';
+ $block_types[] = 'AddToWishlistButton';
+ }
+
if ( wp_is_block_theme() ) {
$block_types[] = 'AddToCartWithOptions\AddToCartWithOptions';
$block_types[] = 'AddToCartWithOptions\QuantitySelector';
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 1cc46589994..501c86d507b 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -639,6 +639,30 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
+ 'cart_save_for_later' => array(
+ 'name' => __( 'Save for Later in Cart', 'woocommerce' ),
+ 'description' => __(
+ 'Let shoppers save cart items to a list to purchase later.',
+ 'woocommerce'
+ ),
+ 'is_experimental' => true,
+ 'enabled_by_default' => false,
+ // Custom option_key as we expect this setting to move out of features to
+ // a cart/checkout settings section.
+ 'option_key' => 'woocommerce_cart_save_for_later_enabled',
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
+ 'product_wishlist' => array(
+ 'name' => __( 'Wishlists', 'woocommerce' ),
+ 'description' => __(
+ 'Let shoppers save products to a wishlist from product pages. Requires the Add to Cart + Options block on the single-product template.',
+ 'woocommerce'
+ ),
+ 'is_experimental' => true,
+ 'enabled_by_default' => false,
+ 'option_key' => 'woocommerce_product_wishlist_enabled',
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
ProductCacheController::FEATURE_NAME => array(
'name' => __( 'Cache Product Objects', 'woocommerce' ),
'description' => __(
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
new file mode 100644
index 00000000000..48fa653dfff
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
@@ -0,0 +1,238 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\Utilities\Users;
+
+/**
+ * A user's saved list of products.
+ */
+class ShopperList {
+ /**
+ * Prefix for per-list usermeta key for list details.
+ */
+ const META_KEY_PREFIX = '_wc_shopper_list_';
+
+ /**
+ * User ID.
+ *
+ * @var int
+ */
+ private $user_id;
+
+ /**
+ * List slug.
+ *
+ * @var string
+ */
+ private $slug;
+
+ /**
+ * Datetime the list was created.
+ *
+ * @var string
+ */
+ private $date_created_gmt;
+
+ /**
+ * Items in the list.
+ *
+ * @var array<string, ShopperListItem>
+ */
+ private $items;
+
+ /**
+ * Private constructor. Use the static factories to obtain concrete instances.
+ *
+ * @param int $user_id Owning user ID.
+ * @param string $slug List slug.
+ * @param string $date_created_gmt MySQL DATETIME, GMT.
+ * @param array<string, ShopperListItem> $items Items keyed by storage key.
+ */
+ private function __construct(
+ int $user_id,
+ string $slug,
+ string $date_created_gmt,
+ array $items
+ ) {
+ $this->user_id = $user_id;
+ $this->slug = $slug;
+ $this->date_created_gmt = $date_created_gmt;
+ $this->items = $items;
+ }
+
+ /**
+ * Load a list by slug. Returns false for any other list that doesn't exist.
+ *
+ * @param string $slug List identifier.
+ * @param int|null $user_id Defaults to the current user.
+ * @return self|false
+ */
+ public static function get_by_slug( string $slug, ?int $user_id = null ) {
+ // Gate disabled or unknown slugs upfront so previously-persisted lists
+ // don't bypass the feature flag (the Store API surfaces this as 404).
+ if ( ! wc_get_container()->get( ShopperListsController::class )->is_enabled( $slug ) ) {
+ return false;
+ }
+
+ $user_id = absint( $user_id ? $user_id : get_current_user_id() );
+ if ( ! $user_id ) {
+ return false;
+ }
+
+ $stored = Users::get_site_user_meta( $user_id, self::META_KEY_PREFIX . $slug );
+
+ if ( is_array( $stored ) ) {
+ return self::from_array( $stored, $user_id );
+ }
+
+ // In-memory list; saved on the first save().
+ return new self(
+ $user_id,
+ $slug,
+ current_time( 'mysql', true ),
+ array()
+ );
+ }
+
+ /**
+ * Get all of the user's lists.
+ *
+ * @param int|null $user_id Defaults to the current user.
+ * @return array<string, self>
+ */
+ public static function get_all_for_user( ?int $user_id = null ): array {
+ $result = array();
+ foreach ( wc_get_container()->get( ShopperListsController::class )->get_enabled_slugs() as $slug ) {
+ $list = self::get_by_slug( $slug, $user_id );
+ if ( $list ) {
+ $result[ $slug ] = $list;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * The list slug (e.g. 'saved-for-later').
+ */
+ public function get_slug(): string {
+ return $this->slug;
+ }
+
+ /**
+ * Creation time as a MySQL DATETIME in GMT.
+ */
+ public function get_date_created_gmt(): string {
+ return $this->date_created_gmt;
+ }
+
+ /**
+ * Add an item, or merge quantities if it already exists.
+ *
+ * @param ShopperListItem $item Item to add.
+ */
+ public function add_item( ShopperListItem $item ): void {
+ $key = $item->get_key();
+
+ if ( isset( $this->items[ $key ] ) ) {
+ $this->items[ $key ] = ShopperListItem::from_array(
+ array_merge(
+ $this->items[ $key ]->to_array(),
+ array( 'quantity' => $this->items[ $key ]->get_quantity() + $item->get_quantity() )
+ )
+ );
+ return;
+ }
+
+ $this->items[ $key ] = $item;
+ }
+
+ /**
+ * Remove an item by key. Returns false if the key wasn't present.
+ *
+ * @param string $key Storage key of the item to remove.
+ */
+ public function remove_item( string $key ): bool {
+ if ( ! isset( $this->items[ $key ] ) ) {
+ return false;
+ }
+ unset( $this->items[ $key ] );
+ return true;
+ }
+
+ /**
+ * Get all items currently in the list.
+ *
+ * @return array<string, ShopperListItem>
+ */
+ public function get_items(): array {
+ return $this->items;
+ }
+
+ /**
+ * Find an item by key.
+ *
+ * @param string $key Storage key.
+ */
+ public function find_item( string $key ): ?ShopperListItem {
+ return $this->items[ $key ] ?? null;
+ }
+
+ /**
+ * Persist the current state to user meta.
+ */
+ public function save(): void {
+ Users::update_site_user_meta(
+ $this->user_id,
+ self::META_KEY_PREFIX . $this->slug,
+ $this->to_array()
+ );
+ }
+
+ /**
+ * Storage / response shape.
+ */
+ public function to_array(): array {
+ $items_array = array();
+ foreach ( $this->items as $key => $item ) {
+ $items_array[ $key ] = $item->to_array();
+ }
+
+ return array(
+ 'slug' => $this->slug,
+ 'date_created_gmt' => $this->date_created_gmt,
+ 'items' => $items_array,
+ );
+ }
+
+ /**
+ * Build a ShopperList from a stored array.
+ *
+ * @param array $data Stored list record.
+ * @param int $user_id Owning user ID.
+ */
+ private static function from_array( array $data, int $user_id ): self {
+ $items = array();
+ if ( ! empty( $data['items'] ) && is_array( $data['items'] ) ) {
+ foreach ( $data['items'] as $key => $item_data ) {
+ if ( ! is_array( $item_data ) ) {
+ continue;
+ }
+
+ try {
+ $items[ (string) $key ] = ShopperListItem::from_array( $item_data );
+ } catch ( \Throwable $e ) {
+ continue;
+ }
+ }
+ }
+
+ return new self(
+ $user_id,
+ $data['slug'] ?? '',
+ $data['date_created_gmt'] ?? current_time( 'mysql', true ),
+ $items
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
new file mode 100644
index 00000000000..139a05d8d62
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
@@ -0,0 +1,404 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductType;
+
+/**
+ * A single saved item within a shopper list.
+ */
+class ShopperListItem {
+ /**
+ * Storage key (md5 of identity tuple).
+ *
+ * @var string
+ */
+ private $key;
+
+ /**
+ * Product ID at the time the item was saved.
+ *
+ * @var int
+ */
+ private $product_id;
+
+ /**
+ * Variation ID at the time the item was saved (0 for non-variable products).
+ *
+ * @var int
+ */
+ private $variation_id;
+
+ /**
+ * Variation attributes captured at save time.
+ *
+ * @var array
+ */
+ private $variation;
+
+ /**
+ * Saved quantity (always 1 in the current contract).
+ *
+ * @var int
+ */
+ private $quantity;
+
+ /**
+ * MySQL DATETIME the item was saved, in GMT.
+ *
+ * @var string
+ */
+ private $date_added_gmt;
+
+ /**
+ * Snapshot of the product title at save time.
+ *
+ * @var string
+ */
+ private $product_title_at_save;
+
+ /**
+ * Resolved product, cached on the instance.
+ *
+ * @var \WC_Product|null
+ */
+ private $product = null;
+
+ /**
+ * Private constructor. Use the static factories to obtain concrete instances.
+ *
+ * @param string $key Storage key (md5 of identity tuple).
+ * @param int $product_id Product ID.
+ * @param int $variation_id Variation ID, or 0.
+ * @param array $variation Variation attributes.
+ * @param int $quantity Saved quantity.
+ * @param string $date_added_gmt MySQL DATETIME, GMT.
+ * @param string $product_title_at_save Title snapshot.
+ */
+ private function __construct(
+ string $key,
+ int $product_id,
+ int $variation_id,
+ array $variation,
+ int $quantity,
+ string $date_added_gmt,
+ string $product_title_at_save
+ ) {
+ $this->key = $key;
+ $this->product_id = $product_id;
+ $this->variation_id = $variation_id;
+ $this->variation = $variation;
+ $this->quantity = $quantity;
+ $this->date_added_gmt = $date_added_gmt;
+ $this->product_title_at_save = $product_title_at_save;
+ }
+
+ /**
+ * Construct from a stored item array (from user_meta).
+ *
+ * @throws \Exception When the stored payload is missing required fields.
+ *
+ * @param array $data Stored item record.
+ */
+ public static function from_array( array $data ): self {
+ if (
+ empty( $data['key'] ) || ! is_string( $data['key'] )
+ || empty( $data['product_id'] ) || ! is_int( $data['product_id'] )
+ || empty( $data['quantity'] ) || ! is_int( $data['quantity'] )
+ ) {
+ throw new \Exception( 'Shopper list item requires "key" (string), "product_id" (int), and "quantity" (int).' );
+ }
+
+ return new self(
+ $data['key'],
+ absint( $data['product_id'] ),
+ absint( $data['variation_id'] ?? 0 ),
+ $data['variation'] ?? array(),
+ absint( $data['quantity'] ),
+ $data['date_added_gmt'] ?? current_time( 'mysql', true ),
+ $data['product_title_at_save'] ?? ''
+ );
+ }
+
+ /**
+ * Construct from a product (or variation) ID and optional payload fields.
+ *
+ * @throws \InvalidArgumentException When the provided variation attributes do not match the variation product.
+ *
+ * @param int $product_or_variation_id Product or variation ID.
+ * @param array $variation Variation attributes keyed by attribute name.
+ * @param int $quantity Saved quantity. Coerced to a minimum of 1.
+ * @return self|null Null if the underlying product can't be resolved.
+ */
+ public static function from_product( int $product_or_variation_id, array $variation = array(), int $quantity = 1 ): ?self {
+ $product = wc_get_product( absint( $product_or_variation_id ) );
+ if ( ! $product ) {
+ return null;
+ }
+
+ if ( $product->is_type( ProductType::VARIATION ) ) {
+ $variation_id = $product->get_id();
+ $product_id = $product->get_parent_id();
+ $variation = self::resolve_variation_attributes( $product, $variation );
+ } elseif ( $product->is_type( ProductType::VARIABLE ) ) {
+ throw new \InvalidArgumentException(
+ esc_html__( 'When saving a variation, product_id must be the variation ID, not the parent product ID.', 'woocommerce' )
+ );
+ } else {
+ $product_id = $product->get_id();
+ $variation_id = 0;
+ $variation = array();
+ }
+
+ return new self(
+ self::generate_key( $product_id, $variation_id, $variation ),
+ $product_id,
+ $variation_id,
+ $variation,
+ max( 1, $quantity ),
+ current_time( 'mysql', true ),
+ $product->get_title()
+ );
+ }
+
+ /**
+ * Resolve and validate the variation attribute array against the variation product.
+ *
+ * Mirrors {@see CartController::parse_variation_data()}: specific values come from
+ * the variation (server-authoritative); "any" slots must be supplied by the caller
+ * with a value that exists on the parent product.
+ *
+ * @throws \InvalidArgumentException When the supplied variation attributes are
+ * missing required values or don't match the
+ * variation product.
+ *
+ * @param \WC_Product $variation_product Variation product.
+ * @param array $requested_attributes Variation attributes supplied by the caller, keyed by `attribute_<slug>`.
+ * @return array
+ */
+ private static function resolve_variation_attributes( \WC_Product $variation_product, array $requested_attributes ): array {
+ $parent = wc_get_product( $variation_product->get_parent_id() );
+ if ( ! $parent || ! $parent->is_type( ProductType::VARIABLE ) || ! $variation_product->is_type( ProductType::VARIATION ) ) {
+ return array();
+ }
+
+ $result = array();
+
+ $all_attributes = array_filter( $parent->get_attributes(), fn( $attribute ) => $attribute->get_variation() );
+ $variation_attributes = wc_get_product_variation_attributes( $variation_product->get_id() );
+
+ foreach ( $all_attributes as $name => $attribute ) {
+ $key = 'attribute_' . $name;
+ $expected = $variation_attributes[ $key ] ?? '';
+
+ // Variation doesn't provide attribute ('any' attribute).
+ if ( '' === $expected ) {
+ if ( ! isset( $requested_attributes[ $key ] ) ) {
+ throw new \InvalidArgumentException(
+ esc_html(
+ sprintf(
+ /* translators: %s: attribute name. */
+ __( 'Attribute "%s" is required.', 'woocommerce' ),
+ $name
+ )
+ )
+ );
+ }
+
+ if ( ! in_array( $requested_attributes[ $key ], $attribute->get_slugs(), true ) ) {
+ throw new \InvalidArgumentException(
+ esc_html(
+ sprintf(
+ /* translators: 1: attribute name, 2: comma-separated allowed values. */
+ __( 'Invalid value posted for "%1$s". Allowed values: %2$s', 'woocommerce' ),
+ $name,
+ implode( ', ', $attribute->get_slugs() )
+ )
+ )
+ );
+ }
+
+ $result[ $key ] = $requested_attributes[ $key ];
+ continue;
+ }//end if
+
+ // Variation provides attribute.
+ if ( isset( $requested_attributes[ $key ] ) && $requested_attributes[ $key ] !== $expected ) {
+ throw new \InvalidArgumentException(
+ esc_html(
+ sprintf(
+ /* translators: 1: attribute name, 2: expected value. */
+ __( 'Invalid value posted for "%1$s". Expected "%2$s".', 'woocommerce' ),
+ $name,
+ $expected
+ )
+ )
+ );
+ }
+
+ $result[ $key ] = $expected;
+ }//end foreach
+
+ return $result;
+ }
+
+ /**
+ * Storage key — also used as the response identifier.
+ */
+ public function get_key(): string {
+ return $this->key;
+ }
+
+ /**
+ * Product ID at save time.
+ */
+ public function get_product_id(): int {
+ return $this->product_id;
+ }
+
+ /**
+ * Variation ID at save time, or 0 for non-variable products.
+ */
+ public function get_variation_id(): int {
+ return $this->variation_id;
+ }
+
+ /**
+ * Saved quantity.
+ */
+ public function get_quantity(): int {
+ return $this->quantity;
+ }
+
+ /**
+ * Variation attributes captured at save time.
+ */
+ public function get_variation_attributes(): array {
+ return $this->variation;
+ }
+
+ /**
+ * Save time as a MySQL DATETIME in GMT.
+ */
+ public function get_date_added_gmt(): string {
+ return $this->date_added_gmt;
+ }
+
+ /**
+ * Snapshot of the product title at save time.
+ */
+ public function get_product_title_at_save(): string {
+ return $this->product_title_at_save;
+ }
+
+ /**
+ * Resolve the live product (or variation) backing this saved item.
+ */
+ public function get_product(): ?\WC_Product {
+ if ( $this->product instanceof \WC_Product ) {
+ return $this->product;
+ }
+ $id = $this->variation_id > 0 ? $this->variation_id : $this->product_id;
+ $product = $id > 0 ? wc_get_product( $id ) : false;
+ $this->product = $product instanceof \WC_Product ? $product : null;
+ return $this->product;
+ }
+
+ /**
+ * Whether the row serves live product data. True when the product (and its
+ * parent, for variations) is `publish`; password-gated products still
+ * qualify since their page renders behind a prompt.
+ */
+ public function is_live(): bool {
+ $product = $this->get_product();
+ if ( ! $product instanceof \WC_Product || ProductStatus::PUBLISH !== $product->get_status() ) {
+ return false;
+ }
+
+ $parent_id = $product->get_parent_id();
+ if ( $parent_id > 0 ) {
+ $parent = wc_get_product( $parent_id );
+
+ if ( ! $parent instanceof \WC_Product || ProductStatus::PUBLISH !== $parent->get_status() ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Whether the product can be added to the cart. Mirrors the catalog gate
+ * (`is_purchasable()` && `is_in_stock()`), but additionally requires the
+ * row to be live and rejects password-gated products (self or parent) —
+ * cart-add can't prompt for a password.
+ */
+ public function is_purchasable(): bool {
+ $product = $this->get_product();
+ if ( ! $this->is_live() || ! $product ) {
+ return false;
+ }
+ if ( ! $product->is_purchasable() || ! $product->is_in_stock() ) {
+ return false;
+ }
+
+ if ( ! empty( $product->get_post_password() ) ) {
+ return false;
+ }
+
+ $parent_id = $product->get_parent_id();
+ if ( $parent_id > 0 ) {
+ $parent = wc_get_product( $parent_id );
+ if ( $parent instanceof \WC_Product && ! empty( $parent->get_post_password() ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Storage shape used to persist into user_meta.
+ */
+ public function to_array(): array {
+ return array(
+ 'key' => $this->key,
+ 'product_id' => $this->product_id,
+ 'variation_id' => $this->variation_id,
+ 'variation' => $this->variation,
+ 'quantity' => $this->quantity,
+ 'date_added_gmt' => $this->date_added_gmt,
+ 'product_title_at_save' => $this->product_title_at_save,
+ );
+ }
+
+ /**
+ * Compute a deterministic item key. Mirrors WC_Cart::generate_cart_id() so the same
+ * product+variation always hashes to the same key, regardless of the input key order
+ * for variation attributes.
+ *
+ * @param int $product_id Product ID.
+ * @param int $variation_id Variation ID, or 0.
+ * @param array $variation Variation attributes.
+ */
+ private static function generate_key( int $product_id, int $variation_id, array $variation ): string {
+ $id_parts = array( $product_id );
+
+ if ( $variation_id ) {
+ $id_parts[] = $variation_id;
+ }
+
+ if ( ! empty( $variation ) ) {
+ ksort( $variation );
+ $variation_key = '';
+ foreach ( $variation as $k => $v ) {
+ $variation_key .= trim( (string) $k ) . trim( (string) $v );
+ }
+ $id_parts[] = $variation_key;
+ }
+
+ return md5( implode( '_', $id_parts ) );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
new file mode 100644
index 00000000000..a41a6549afa
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
@@ -0,0 +1,331 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+/**
+ * Shared markup helpers for blocks that render a shopper-list item card
+ * (Saved for Later, Wishlist, …). Static helpers, not an abstract base —
+ * the two blocks' lifecycles diverge enough (auto-injected vs merchant-
+ * placed, different actions, different empty-state gating) that inheritance
+ * is not a clean fit. Consumers stitch the fragments together with their
+ * own quantity / action button / heading bits.
+ *
+ * Any change here is co-reviewed with every consuming block — drift in the
+ * shared row shape will break first paint for whoever didn't get the memo.
+ */
+final class ShopperListRenderer {
+
+ /**
+ * Shared CSS root class for the row. Each section helper outputs
+ * BEM-style modifiers off this base (`__image-slot`, `__remove`, …).
+ */
+ public const ROW_CLASS = 'wc-block-shopper-list-item';
+
+ /**
+ * Wrap `$inner` in the block's outer `<section><ul>…</ul></section>`
+ * grid scaffold. `$wrapper_attrs` are merged with the block's wrapper
+ * attributes via `get_block_wrapper_attributes()`.
+ *
+ * Trust contract: callers are responsible for ensuring `$inner` and
+ * `$before_list` contain only safe, escaped HTML — typically composed
+ * from the section helpers below, never from raw schema/request input.
+ *
+ * @param array<string, mixed> $wrapper_attrs Attributes for the outer `<section>`.
+ * @param string $list_class Class attribute for the inner `<ul>`.
+ * @param string $inner Markup placed inside the `<ul>` (template + SSR rows + empty state).
+ * @param string $before_list Markup placed between `<section>` and `<ul>` (header, notices region).
+ * @return string
+ */
+ public static function render_grid_wrapper( array $wrapper_attrs, string $list_class, string $inner, string $before_list = '' ): string {
+ return sprintf(
+ '<section %1$s>%2$s<ul class="%3$s">%4$s</ul></section>',
+ get_block_wrapper_attributes( $wrapper_attrs ),
+ $before_list,
+ esc_attr( $list_class ),
+ $inner
+ );
+ }
+
+ /**
+ * Wrap `$row_inner_markup` in a `<template data-wp-each>` element that
+ * iAPI uses to render new rows. `$row_inner_markup` is the inner HTML
+ * for the `<li>` — everything between `<li>` and `</li>`.
+ *
+ * Trust contract: caller is responsible for ensuring `$row_inner_markup`
+ * contains only safe, escaped HTML.
+ *
+ * @param string $row_inner_markup Inner markup for the `<li>`.
+ * @return string
+ */
+ public static function render_each_template( string $row_inner_markup ): string {
+ return sprintf(
+ '<template data-wp-each--list-item="state.currentItems" data-wp-each-key="context.listItem.key"><li class="%1$s">%2$s</li></template>',
+ esc_attr( self::ROW_CLASS ),
+ $row_inner_markup
+ );
+ }
+
+ /**
+ * Wrap `$row_inner_markup` in an SSR `<li data-wp-each-child>` element
+ * seeded with the per-row iAPI context derived from `$item`. iAPI's
+ * hydration treats this as a no-op diff against the `<template>` if
+ * the inner markup matches.
+ *
+ * Trust contract: caller is responsible for ensuring `$row_inner_markup`
+ * contains only safe, escaped HTML.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @param string $row_inner_markup Inner markup for the `<li>`.
+ * @return string
+ */
+ public static function render_each_child( array $item, string $row_inner_markup ): string {
+ $context = array( 'listItem' => $item );
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_interactivity_data_wp_context() returns a safely-encoded attribute pair; $row_inner_markup is composed of escaped fragments from the section helpers below.
+ return sprintf(
+ '<li class="%1$s" data-wp-each-child %2$s>%3$s</li>',
+ esc_attr( self::ROW_CLASS ),
+ wp_interactivity_data_wp_context( $context ),
+ $row_inner_markup
+ );
+ }
+
+ /**
+ * Render the image + title + price triplet for the template-mode row
+ * (no static attrs; bindings only). Identical between consumer blocks.
+ *
+ * @return string
+ */
+ public static function render_template_common_row(): string {
+ ob_start();
+ ?>
+ <div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+ <a data-wp-bind--href="context.listItem.permalink">
+ <span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot" data-wp-context='{"htmlField":"image_html"}' data-wp-watch="callbacks.updateInnerHtml"></span>
+ </a>
+ <button
+ type="button"
+ class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove"
+ data-wp-on--click="actions.onClickRemove"
+ data-wp-bind--aria-label="state.currentItemRemoveLabel"
+ data-wp-bind--disabled="state.isCurrentItemPending"
+ >
+ <?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+ </button>
+ <span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation" data-wp-bind--hidden="!state.currentItemVariationLabel" data-wp-text="state.currentItemVariationLabel"></span>
+ </div>
+ <h2 class="wp-block-post-title has-text-align-center has-medium-font-size">
+ <a data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"></a>
+ </h2>
+ <div class="price wc-block-components-product-price has-text-align-center has-small-font-size" data-wp-bind--hidden="state.isPriceHidden" data-wp-context='{"htmlField":"price_html"}' data-wp-watch="callbacks.updateInnerHtml"></div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Render the image + title + price triplet for the SSR-mode row, with
+ * values populated from `$item` and `$remove_aria_label_template`. The
+ * binding directives match the template-mode markup so iAPI's hydration
+ * is a no-op diff after first paint.
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @param string $remove_aria_label_template Sprintf template for the remove button's aria-label. `%s` is replaced with the product name.
+ * @return string
+ */
+ public static function render_ssr_common_row( array $item, string $remove_aria_label_template ): string {
+ $is_live = ! empty( $item['is_live'] );
+ $name = (string) ( $item['name'] ?? '' );
+ $permalink = (string) ( $item['permalink'] ?? '' );
+ $alt = html_entity_decode( $name, ENT_QUOTES, 'UTF-8' );
+ $image_html = (string) ( $item['image_html'] ?? '' );
+ $price_html = (string) ( $item['price_html'] ?? '' );
+ $variation_label = self::get_variation_label( $item );
+ $remove_aria = sprintf( $remove_aria_label_template, $alt );
+ $is_price_hidden = '' === $price_html;
+ // Tombstone rows (`is_live=false` or empty permalink) render `<a>`
+ // without an href — keeps the element shape stable for iAPI
+ // reconciliation against the live-row template, and the CSS in the
+ // shared partial drops link affordances when the anchor has no href.
+ $href_attr = $is_live && '' !== $permalink ? 'href="' . esc_url( $permalink ) . '"' : '';
+
+ ob_start();
+ ?>
+ <div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+ <a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink">
+ <span
+ class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot"
+ data-wp-context='{"htmlField":"image_html"}'
+ data-wp-watch="callbacks.updateInnerHtml"
+ >
+ <?php echo wp_kses_post( $image_html ); ?>
+ </span>
+ </a>
+ <button
+ type="button"
+ class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove"
+ aria-label="<?php echo esc_attr( $remove_aria ); ?>"
+ data-wp-on--click="actions.onClickRemove"
+ data-wp-bind--aria-label="state.currentItemRemoveLabel"
+ data-wp-bind--disabled="state.isCurrentItemPending"
+ >
+ <?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+ </button>
+ <span
+ class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation"
+ data-wp-bind--hidden="!state.currentItemVariationLabel"
+ data-wp-text="state.currentItemVariationLabel"
+ <?php
+ if ( '' === $variation_label ) {
+ echo 'hidden';
+ }
+ ?>
+ ><?php echo esc_html( $variation_label ); ?></span>
+ </div>
+ <h2 class="wp-block-post-title has-text-align-center has-medium-font-size">
+ <a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"><?php echo esc_html( $alt ); ?></a>
+ </h2>
+ <div
+ class="price wc-block-components-product-price has-text-align-center has-small-font-size"
+ data-wp-bind--hidden="state.isPriceHidden"
+ data-wp-context='{"htmlField":"price_html"}'
+ data-wp-watch="callbacks.updateInnerHtml"
+ <?php
+ if ( $is_price_hidden ) {
+ echo 'hidden';
+ }
+ ?>
+ >
+ <?php echo wp_kses_post( $price_html ); ?>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Empty-state `<li>` that the block toggles on once `state.isEmpty`
+ * flips. `$start_hidden = true` makes SSR ship with `hidden` so the
+ * message doesn't flash for shoppers whose list is being populated
+ * client-side. `$start_hidden = false` is for blocks (e.g. Wishlist)
+ * where the message should show on first paint when the list is empty.
+ *
+ * @param string $message Visible empty-state message.
+ * @param string $css_class Class attribute for the `<li>`.
+ * @param bool $start_hidden Whether the `<li>` should be `hidden` on first paint.
+ * @return string
+ */
+ public static function render_empty_state( string $message, string $css_class, bool $start_hidden = true ): string {
+ return sprintf(
+ '<li class="%1$s" data-wp-bind--hidden="!state.isEmpty"%2$s>%3$s</li>',
+ esc_attr( $css_class ),
+ $start_hidden ? ' hidden' : '',
+ esc_html( $message )
+ );
+ }
+
+ /**
+ * Render the iAPI store-notices region used by the row-level error
+ * banners. Mirrors `AddToCartWithOptions::render_interactivity_notices_region()`
+ * — keep in sync if the shape changes.
+ *
+ * @param string $wrapper_class Class attribute for the outer `<div>`.
+ * @return string
+ */
+ public static function render_interactivity_notices_region( string $wrapper_class ): string {
+ ob_start();
+ ?>
+ <div class="<?php echo esc_attr( $wrapper_class ); ?> wc-block-components-notices" data-wp-interactive="woocommerce/store-notices" data-wp-bind--hidden="!context.notices.length" hidden>
+ <template data-wp-each--notice="context.notices" data-wp-each-key="context.notice.id">
+ <div
+ class="wc-block-components-notice-banner"
+ data-wp-class--is-error="state.isError"
+ data-wp-class--is-success="state.isSuccess"
+ data-wp-class--is-info="state.isInfo"
+ data-wp-class--is-dismissible="context.notice.dismissible"
+ data-wp-bind--role="state.role"
+ data-wp-watch="callbacks.injectIcon"
+ >
+ <div class="wc-block-components-notice-banner__content">
+ <span data-wp-init="callbacks.renderNoticeContent" aria-live="assertive" aria-atomic="true"></span>
+ </div>
+ <button
+ type="button"
+ data-wp-bind--hidden="!context.notice.dismissible"
+ class="wc-block-components-button wp-element-button wc-block-components-notice-banner__dismiss contained"
+ aria-label="<?php esc_attr_e( 'Dismiss this notice', 'woocommerce' ); ?>"
+ data-wp-on--click="actions.removeNotice"
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" />
+ </svg>
+ </button>
+ </div>
+ </template>
+ </div>
+ <?php
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Markup for the trash icon used in the remove-item button. Mirrors the
+ * `trash` icon from `@wordpress/icons` that the cart line item uses for
+ * `wc-block-cart-item__remove-link`, inlined here so SSR first paint
+ * matches what JS would render after hydration. `currentColor` lets the
+ * surrounding badge wrapper drive the fill.
+ *
+ * @return string
+ */
+ public static function get_remove_icon_svg(): string {
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 5.5A2.25 2.25 0 0 0 9.878 7h4.244A2.251 2.251 0 0 0 12 5.5ZM12 4a3.751 3.751 0 0 0-3.675 3H5v1.5h1.27l.818 8.997a2.75 2.75 0 0 0 2.739 2.501h4.347a2.75 2.75 0 0 0 2.738-2.5L17.73 8.5H19V7h-3.325A3.751 3.751 0 0 0 12 4Zm4.224 4.5H7.776l.806 8.861a1.25 1.25 0 0 0 1.245 1.137h4.347a1.25 1.25 0 0 0 1.245-1.137l.805-8.861Z"/></svg>';
+ }
+
+ /**
+ * Markup for the empty-star icon. Mirrors `starEmpty` from
+ * `@wordpress/icons`, inlined here so SSR first paint matches what JS
+ * renders after hydration. `currentColor` lets the surrounding button
+ * drive the fill.
+ *
+ * @return string
+ */
+ public static function get_star_empty_svg(): string {
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M9.706 8.646a.25.25 0 01-.188.137l-4.626.672a.25.25 0 00-.139.427l3.348 3.262a.25.25 0 01.072.222l-.79 4.607a.25.25 0 00.362.264l4.138-2.176a.25.25 0 01.233 0l4.137 2.175a.25.25 0 00.363-.263l-.79-4.607a.25.25 0 01.072-.222l3.347-3.262a.25.25 0 00-.139-.427l-4.626-.672a.25.25 0 01-.188-.137l-2.069-4.192a.25.25 0 00-.448 0L9.706 8.646zM12 7.39l-.948 1.921a1.75 1.75 0 01-1.317.957l-2.12.308 1.534 1.495c.412.402.6.982.503 1.55l-.362 2.11 1.896-.997a1.75 1.75 0 011.629 0l1.895.997-.362-2.11a1.75 1.75 0 01.504-1.55l1.533-1.495-2.12-.308a1.75 1.75 0 01-1.317-.957L12 7.39z"/></svg>';
+ }
+
+ /**
+ * Markup for the filled-star icon. Mirrors `starFilled` from
+ * `@wordpress/icons`, inlined here so SSR first paint matches what JS
+ * renders after hydration. `currentColor` lets the surrounding button
+ * drive the fill.
+ *
+ * @return string
+ */
+ public static function get_star_filled_svg(): string {
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M11.776 4.454a.25.25 0 01.448 0l2.069 4.192a.25.25 0 00.188.137l4.626.672a.25.25 0 01.139.426l-3.348 3.263a.25.25 0 00-.072.222l.79 4.607a.25.25 0 01-.362.263l-4.138-2.175a.25.25 0 00-.232 0l-4.138 2.175a.25.25 0 01-.363-.263l.79-4.607a.25.25 0 00-.071-.222L4.754 9.881a.25.25 0 01.139-.426l4.626-.672a.25.25 0 00.188-.137l2.069-4.192z"/></svg>';
+ }
+
+ /**
+ * Build a comma-separated variation label like "Color: Blue, Size: M".
+ *
+ * @param array<string, mixed> $item Schema-shape item.
+ * @return string
+ */
+ public static function get_variation_label( array $item ): string {
+ $variation = $item['variation'] ?? array();
+ if ( ! is_array( $variation ) || empty( $variation ) ) {
+ return '';
+ }
+ $parts = array();
+ foreach ( $variation as $entry ) {
+ if ( ! is_array( $entry ) ) {
+ continue;
+ }
+ $attribute = isset( $entry['attribute'] ) ? html_entity_decode( (string) $entry['attribute'], ENT_QUOTES, 'UTF-8' ) : '';
+ $value = isset( $entry['value'] ) ? html_entity_decode( (string) $entry['value'], ENT_QUOTES, 'UTF-8' ) : '';
+ if ( '' === $attribute && '' === $value ) {
+ continue;
+ }
+ $parts[] = $attribute . ': ' . $value;
+ }
+ return implode( ', ', $parts );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
new file mode 100644
index 00000000000..18e403a55ee
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
@@ -0,0 +1,160 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Tracks which shopper-list types are turned on and registers the
+ * user-facing pieces that depend on each.
+ *
+ * @internal Just for internal use.
+ */
+final class ShopperListsController implements RegisterHooksInterface {
+
+ /**
+ * Known list slugs and the feature flag that controls each.
+ */
+ private const SUPPORTED_LISTS = array(
+ 'saved-for-later' => 'cart_save_for_later',
+ 'wishlist' => 'product_wishlist',
+ );
+
+ /**
+ * Wishlist My Account endpoint slug. Wrapped in a method (rather than
+ * a constant) so a future filter or settings hook can override it
+ * without touching every call site.
+ */
+ public function get_wishlist_endpoint(): string {
+ return 'wishlist';
+ }
+
+ /**
+ * Whether a given list type is on, or whether any list type is on
+ * when no slug is passed.
+ *
+ * @param string|null $list_slug List slug, or null to ask about any type.
+ */
+ public function is_enabled( ?string $list_slug = null ): bool {
+ if ( null === $list_slug ) {
+ foreach ( self::SUPPORTED_LISTS as $feature ) {
+ if ( FeaturesUtil::feature_is_enabled( $feature ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+ $feature = self::SUPPORTED_LISTS[ $list_slug ] ?? null;
+ return null !== $feature && FeaturesUtil::feature_is_enabled( $feature );
+ }
+
+ /**
+ * Slugs of all currently-enabled lists, in declaration order.
+ *
+ * @return string[]
+ */
+ public function get_enabled_slugs(): array {
+ return array_keys(
+ array_filter(
+ self::SUPPORTED_LISTS,
+ static fn( string $feature ): bool => FeaturesUtil::feature_is_enabled( $feature )
+ )
+ );
+ }
+
+ /**
+ * Register hooks.
+ */
+ public function register(): void {
+ add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 1 );
+ add_action( 'init', array( $this, 'maybe_register_wishlist_endpoint' ), 5 );
+ }
+
+ /**
+ * Register the wishlist endpoint.
+ */
+ public function maybe_register_wishlist_endpoint(): void {
+ if ( ! $this->is_enabled( 'wishlist' ) ) {
+ return;
+ }
+
+ $endpoint = $this->get_wishlist_endpoint();
+ add_filter( 'woocommerce_get_query_vars', array( $this, 'add_wishlist_query_var' ) );
+ add_filter( 'woocommerce_account_menu_items', array( $this, 'add_wishlist_menu_item' ) );
+ add_filter( 'woocommerce_endpoint_' . $endpoint . '_title', array( $this, 'wishlist_endpoint_title' ) );
+ add_action( 'woocommerce_account_' . $endpoint . '_endpoint', array( $this, 'render_wishlist_endpoint' ) );
+ }
+
+ /**
+ * Flush rewrite rules when the wishlist feature is turned on or off.
+ *
+ * @param string $feature_id The feature that changed.
+ */
+ public function maybe_flush_rewrite_rules( string $feature_id ): void {
+ if ( 'product_wishlist' === $feature_id ) {
+ update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+ }
+ }
+
+ /**
+ * Register the `wishlist` query var.
+ *
+ * @param array $vars Existing query vars keyed by slug.
+ */
+ public function add_wishlist_query_var( $vars ): array {
+ if ( ! is_array( $vars ) ) {
+ return array();
+ }
+
+ $endpoint = $this->get_wishlist_endpoint();
+ $vars[ $endpoint ] = $endpoint;
+ return $vars;
+ }
+
+ /**
+ * Insert the Wishlist link just before the logout link.
+ *
+ * @param array $items Existing menu items keyed by slug.
+ */
+ public function add_wishlist_menu_item( $items ): array {
+ if ( ! is_array( $items ) ) {
+ return array();
+ }
+
+ $wishlist_endpoint = $this->get_wishlist_endpoint();
+ $wishlist_label = __( 'Wishlist', 'woocommerce' );
+
+ // Insert the wishlist item before the logout item, or at the end if not present.
+ $logout_pos = array_search( 'customer-logout', array_keys( $items ), true );
+ if ( false === $logout_pos ) {
+ $items[ $wishlist_endpoint ] = $wishlist_label;
+ } else {
+ $items = array_slice( $items, 0, $logout_pos, true )
+ + array( $wishlist_endpoint => $wishlist_label )
+ + array_slice( $items, $logout_pos, null, true );
+ }
+ return $items;
+ }
+
+ /**
+ * Wishlist endpoint page title.
+ *
+ * @param string $title Default title.
+ */
+ public function wishlist_endpoint_title( $title ): string {
+ return __( 'Wishlist', 'woocommerce' );
+ }
+
+ /**
+ * Render the wishlist endpoint by dispatching to the
+ * `woocommerce/wishlist` block. The block handles the empty state,
+ * logged-out guard, asset enqueues, and item rendering.
+ */
+ public function render_wishlist_endpoint(): void {
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the block string is a static literal; `do_blocks()` invokes the registered block's render callback, which is responsible for its own escaping.
+ echo do_blocks( '<!-- wp:woocommerce/wishlist /-->' );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
new file mode 100644
index 00000000000..ff4985e906e
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
@@ -0,0 +1,253 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * GET / POST on /shopper-lists/{slug}/items.
+ *
+ * GET returns the items in a list.
+ * POST saves an item to the list either from an existing cart line or from direct item payload fields.
+ */
+class ShopperListItems extends AbstractRoute {
+ // Stopgap CSRF guard, replaced once the upstream trait lands on trunk.
+ use ShopperListsNonceCheck;
+
+ /**
+ * Route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'shopper-list-items';
+
+ /**
+ * Schema identifier this route uses.
+ *
+ * @var string
+ */
+ const SCHEMA_TYPE = 'shopper-list-item';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path regex for this REST route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/shopper-lists/(?P<slug>[a-z0-9-]+)/items';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array
+ */
+ public function get_args() {
+ return array(
+ 'args' => array(
+ 'slug' => array(
+ 'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ 'args' => array(
+ 'cart_item_key' => array(
+ 'description' => __( 'Existing cart item key to copy into the list.', 'woocommerce' ),
+ 'type' => 'string',
+ ),
+ 'product_id' => array(
+ 'description' => __( 'Product or variation ID to save. Required when cart_item_key is not supplied.', 'woocommerce' ),
+ 'type' => 'integer',
+ ),
+ 'variation' => array(
+ 'description' => __( 'Chosen attributes (for variations).', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => array( 'view', 'edit' ),
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'attribute' => array(
+ 'description' => __( 'Variation attribute name.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'value' => array(
+ 'description' => __( 'Variation attribute value.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ ),
+ ),
+ ),
+ 'quantity' => array(
+ 'description' => __( 'Quantity for the saved item.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ ),
+ ),
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Return the items in the requested list.
+ *
+ * @throws RouteException When the list doesn't exist.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_response( \WP_REST_Request $request ) {
+ $list = ShopperList::get_by_slug( (string) $request['slug'] );
+ if ( ! $list ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+ }
+
+ $items = array_values( $list->get_items() );
+ $this->prime_product_caches_for_items( $items );
+
+ $response = array();
+ foreach ( $items as $item ) {
+ $response[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $item, $request ) );
+ }
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Add an item to the requested list from cart_item_key or direct product payload fields.
+ *
+ * @throws RouteException On validation failure.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_post_response( \WP_REST_Request $request ) {
+ $list = ShopperList::get_by_slug( (string) $request['slug'] );
+
+ if ( ! $list ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+ }
+
+ [ $lookup_id, $variation, $quantity ] = $this->resolve_item_payload( $request );
+
+ try {
+ $item = ShopperListItem::from_product( $lookup_id, $variation, $quantity );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_invalid_variation', esc_html( $e->getMessage() ), 400 );
+ }
+ if ( ! $item ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_unknown_product', esc_html__( 'No product exists for the supplied item.', 'woocommerce' ), 404 );
+ }
+
+ $list->add_item( $item );
+ $list->save();
+
+ $saved = $list->find_item( $item->get_key() ) ?? $item;
+ return new \WP_REST_Response( $this->schema->get_item_response( $saved ), 201 );
+ }
+
+ /**
+ * Resolve the POST input into a uniform payload (product lookup id, variation, quantity).
+ *
+ * Accepts either an existing cart_item_key, or direct product_id/variation_id/variation.
+ *
+ * @throws RouteException When neither a cart_item_key nor a product_id is supplied, or the cart_item_key is unknown.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return array{0:int,1:array,2:int} `[ lookup_id, variation, quantity ]`.
+ */
+ private function resolve_item_payload( \WP_REST_Request $request ): array {
+ $cart_item_key = (string) $request->get_param( 'cart_item_key' );
+
+ if ( $cart_item_key ) {
+ if ( ! did_action( 'woocommerce_load_cart_from_session' ) || ! wc()->cart ) {
+ wc_load_cart();
+ }
+
+ $cart_contents = wc()->cart->get_cart();
+ if ( empty( $cart_contents[ $cart_item_key ] ) ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_invalid_cart_item_key', esc_html__( 'No cart item exists for the supplied key.', 'woocommerce' ), 404 );
+ }
+
+ $line = $cart_contents[ $cart_item_key ];
+ $product_id = absint( $line['product_id'] ?? 0 );
+ $variation_id = absint( $line['variation_id'] ?? 0 );
+ $variation_attrs = isset( $line['variation'] ) && is_array( $line['variation'] ) ? $line['variation'] : array();
+
+ return array(
+ $variation_id ? $variation_id : $product_id,
+ $variation_attrs,
+ absint( $line['quantity'] ?? 1 ),
+ );
+ }//end if
+
+ $product_id = absint( $request->get_param( 'product_id' ) );
+ if ( ! $product_id ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_missing_item_input', esc_html__( 'Provide cart_item_key or product_id.', 'woocommerce' ), 400 );
+ }
+
+ $variation = wp_list_pluck( (array) $request->get_param( 'variation' ), 'value', 'attribute' );
+
+ return array(
+ $product_id,
+ (array) array_combine(
+ array_map( 'wc_variation_attribute_name', array_keys( $variation ) ),
+ array_values( $variation )
+ ),
+ absint( $request->get_param( 'quantity' ) ),
+ );
+ }
+
+ /**
+ * Prime post caches before the per-item product lookup loop in the schema.
+ *
+ * @param ShopperListItem[] $items Items.
+ */
+ private function prime_product_caches_for_items( array $items ): void {
+ $ids = array_map(
+ fn( $item ) => $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id(),
+ $items
+ );
+
+ _prime_post_caches( array_unique( $ids ) );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
new file mode 100644
index 00000000000..ab44a9ff839
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
@@ -0,0 +1,102 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * DELETE /shopper-lists/{slug}/items/{key}.
+ */
+class ShopperListItemsByKey extends AbstractRoute {
+ // Stopgap CSRF guard, replaced once the upstream trait lands on trunk.
+ use ShopperListsNonceCheck;
+
+ /**
+ * Route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'shopper-list-items-by-key';
+
+ /**
+ * Schema identifier this route uses.
+ *
+ * @var string
+ */
+ const SCHEMA_TYPE = 'shopper-list-item';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path regex for this REST route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/shopper-lists/(?P<slug>[a-z0-9-]+)/items/(?P<key>[\w-]{32})';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array
+ */
+ public function get_args() {
+ return array(
+ 'args' => array(
+ 'slug' => array(
+ 'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+ 'type' => 'string',
+ ),
+ 'key' => array(
+ 'description' => __( 'Item key.', 'woocommerce' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => \WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Delete a single item from a list.
+ *
+ * @throws RouteException When the list or item doesn't exist.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_delete_response( \WP_REST_Request $request ) {
+ $list = ShopperList::get_by_slug( (string) $request['slug'] );
+
+ if ( ! $list ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+ }
+
+ if ( ! $list->remove_item( (string) $request['key'] ) ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_item_not_found', esc_html__( 'That item isn\'t in your saved list anymore.', 'woocommerce' ), 404 );
+ }
+
+ $list->save();
+
+ return new \WP_REST_Response( null, 204 );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
new file mode 100644
index 00000000000..18302622d76
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
@@ -0,0 +1,83 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+
+/**
+ * GET /shopper-lists — collection of the current user's shopper lists.
+ */
+class ShopperLists extends AbstractRoute {
+ /**
+ * Route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'shopper-lists';
+
+ /**
+ * Schema identifier this route uses.
+ *
+ * @var string
+ */
+ const SCHEMA_TYPE = 'shopper-list';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path regex for this REST route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/shopper-lists';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array
+ */
+ public function get_args() {
+ return array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Return the lists for the current user.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_response( \WP_REST_Request $request ) {
+ $response = array();
+
+ foreach ( ShopperList::get_all_for_user() as $list ) {
+ $response[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $list, $request ) );
+ }
+
+ return rest_ensure_response( $response );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
new file mode 100644
index 00000000000..6769834e54a
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
@@ -0,0 +1,92 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * GET /shopper-lists/{slug} — metadata for a single list.
+ */
+class ShopperListsBySlug extends AbstractRoute {
+ /**
+ * Route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'shopper-lists-by-slug';
+
+ /**
+ * Schema identifier this route uses.
+ *
+ * @var string
+ */
+ const SCHEMA_TYPE = 'shopper-list';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path regex for this REST route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/shopper-lists/(?P<slug>[a-z0-9-]+)';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array
+ */
+ public function get_args() {
+ return array(
+ 'args' => array(
+ 'slug' => array(
+ 'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Return the list metadata for the requested slug.
+ *
+ * @throws RouteException When the list doesn't exist.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_response( \WP_REST_Request $request ) {
+ $list = ShopperList::get_by_slug( (string) $request['slug'] );
+
+ if ( ! $list ) {
+ throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+ }
+
+ return rest_ensure_response( $this->prepare_item_for_response( $list, $request ) );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
new file mode 100644
index 00000000000..d542f30dcf7
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+/**
+ * Stopgap CSRF guard for the write-capable shopper-lists routes.
+ *
+ * Enforces a `wc_store_api` Nonce header on writes and refreshes the
+ * client nonce via response headers on every reply. Same shape as the
+ * cart's existing flow, scoped to the nonce concern.
+ *
+ * To be replaced by a reusable Store API-wide nonce trait once that
+ * lands on trunk.
+ *
+ * @internal
+ */
+trait ShopperListsNonceCheck {
+ /**
+ * Nonce action used to sign and verify Store API write requests.
+ *
+ * @var string
+ */
+ private static $store_api_nonce_action = 'wc_store_api';
+
+ /**
+ * Override of {@see AbstractRoute::get_response} that enforces the
+ * `wc_store_api` Nonce header on writes and refreshes it on every reply.
+ *
+ * @param \WP_REST_Request $request Request object.
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ * @return \WP_REST_Response
+ */
+ public function get_response( \WP_REST_Request $request ) {
+ if ( $this->is_write_request( $request ) ) {
+ $nonce_check = $this->check_store_api_nonce( $request );
+ if ( is_wp_error( $nonce_check ) ) {
+ return $this->add_nonce_response_headers( $this->error_to_response( $nonce_check ) );
+ }
+ }
+
+ $response = parent::get_response( $request );
+
+ return $this->add_nonce_response_headers( rest_ensure_response( $response ) );
+ }
+
+ /**
+ * Whether the request mutates state. Mirrors `AbstractCartRoute::is_update_request`.
+ *
+ * @param \WP_REST_Request $request Request object.
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ * @return bool
+ */
+ private function is_write_request( \WP_REST_Request $request ): bool {
+ return in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
+ }
+
+ /**
+ * Verify the `Nonce` request header against the `wc_store_api` action.
+ *
+ * @param \WP_REST_Request $request Request object.
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ * @return true|\WP_Error True on success, WP_Error on missing/invalid nonce.
+ */
+ private function check_store_api_nonce( \WP_REST_Request $request ) {
+ /**
+ * Filters whether to disable the Store API nonce check.
+ *
+ * This filter is documented in src/StoreApi/Routes/V1/AbstractCartRoute.php.
+ *
+ * @since 4.5.0
+ *
+ * @param bool $disable_nonce_check If true, nonce checks will be disabled.
+ */
+ if ( apply_filters( 'woocommerce_store_api_disable_nonce_check', false ) ) {
+ return true;
+ }
+
+ $nonce = $request->get_header( 'Nonce' );
+ if ( null === $nonce || '' === $nonce ) {
+ return $this->get_route_error_response(
+ 'woocommerce_rest_missing_nonce',
+ __( 'Missing the Nonce header. This endpoint requires a valid nonce.', 'woocommerce' ),
+ 401
+ );
+ }
+
+ if ( ! wp_verify_nonce( $nonce, self::$store_api_nonce_action ) ) {
+ return $this->get_route_error_response(
+ 'woocommerce_rest_invalid_nonce',
+ __( 'Nonce is invalid.', 'woocommerce' ),
+ 403
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Attach a fresh `wc_store_api` nonce to the response.
+ *
+ * @param \WP_REST_Response $response Response object.
+ * @return \WP_REST_Response
+ */
+ private function add_nonce_response_headers( \WP_REST_Response $response ): \WP_REST_Response {
+ $response->header( 'Nonce', wp_create_nonce( self::$store_api_nonce_action ) );
+ $response->header( 'Nonce-Timestamp', (string) time() );
+ $response->header( 'Cache-Control', 'no-store' );
+
+ return $response;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index 17d457eb39a..6de8e81baed 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\StoreApi;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -39,7 +40,7 @@ class RoutesController {
public function __construct( SchemaController $schema_controller ) {
$this->schema_controller = $schema_controller;
$this->routes = [
- 'v1' => [
+ 'v1' => [
Routes\V1\Batch::IDENTIFIER => Routes\V1\Batch::class,
Routes\V1\Cart::IDENTIFIER => Routes\V1\Cart::class,
Routes\V1\CartAddItem::IDENTIFIER => Routes\V1\CartAddItem::class,
@@ -71,11 +72,18 @@ class RoutesController {
Routes\V1\ProductsById::IDENTIFIER => Routes\V1\ProductsById::class,
Routes\V1\ProductsBySlug::IDENTIFIER => Routes\V1\ProductsBySlug::class,
],
- 'private' => [
+ 'private' => [
// This route should be moved outside of the Store API namespace.
Routes\V1\Patterns::IDENTIFIER => Routes\V1\Patterns::class,
],
- 'agentic' => [
+ 'shopper_lists' => [
+ // Gated by ShopperListsController — registered only when at least one shopper-list feature is enabled.
+ Routes\V1\ShopperLists::IDENTIFIER => Routes\V1\ShopperLists::class,
+ Routes\V1\ShopperListsBySlug::IDENTIFIER => Routes\V1\ShopperListsBySlug::class,
+ Routes\V1\ShopperListItems::IDENTIFIER => Routes\V1\ShopperListItems::class,
+ Routes\V1\ShopperListItemsByKey::IDENTIFIER => Routes\V1\ShopperListItemsByKey::class,
+ ],
+ 'agentic' => [
// Agentic Commerce Protocol endpoints.
Routes\V1\Agentic\CheckoutSessions::IDENTIFIER => Routes\V1\Agentic\CheckoutSessions::class,
Routes\V1\Agentic\CheckoutSessionsUpdate::IDENTIFIER => Routes\V1\Agentic\CheckoutSessionsUpdate::class,
@@ -92,6 +100,10 @@ class RoutesController {
$this->register_routes( 'v1', self::$api_namespace . '/v1' );
$this->register_routes( 'private', 'wc/private' );
+ if ( wc_get_container()->get( ShopperListsController::class )->is_enabled() ) {
+ $this->register_routes( 'shopper_lists', self::$api_namespace . '/v1' );
+ }
+
if ( FeaturesUtil::feature_is_enabled( 'agentic_checkout' ) ) {
$this->register_routes( 'agentic', 'wc/agentic/v1' );
}
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 7db833a256f..7aa16ca21a3 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -56,6 +56,8 @@ class SchemaController {
Schemas\V1\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class,
Schemas\V1\ProductReviewSchema::IDENTIFIER => Schemas\V1\ProductReviewSchema::class,
Schemas\V1\PatternsSchema::IDENTIFIER => Schemas\V1\PatternsSchema::class,
+ Schemas\V1\ShopperListSchema::IDENTIFIER => Schemas\V1\ShopperListSchema::class,
+ Schemas\V1\ShopperListItemSchema::IDENTIFIER => Schemas\V1\ShopperListItemSchema::class,
Schemas\V1\Agentic\CheckoutSessionSchema::IDENTIFIER => Schemas\V1\Agentic\CheckoutSessionSchema::class,
],
];
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
new file mode 100644
index 00000000000..9fdc2a61ba7
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
@@ -0,0 +1,328 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait;
+
+/**
+ * ShopperListItemSchema class.
+ *
+ * Serializes a {@see ShopperListItem}. Renders live product fields when the
+ * item reports `is_live`, and falls back to at-save snapshot data otherwise.
+ */
+class ShopperListItemSchema extends AbstractSchema {
+ // We only call format_variation_data(); see phpstan.neon for the related suppressions.
+ use ProductItemTrait;
+
+ /**
+ * The schema item name.
+ *
+ * @var string
+ */
+ protected $title = 'shopper_list_item';
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'shopper-list-item';
+
+ /**
+ * Image attachment schema instance.
+ *
+ * @var ImageAttachmentSchema
+ */
+ protected $image_attachment_schema;
+
+ /**
+ * Constructor.
+ *
+ * @throws \RuntimeException When the ImageAttachmentSchema is not registered.
+ *
+ * @param ExtendSchema $extend Rest Extending instance.
+ * @param SchemaController $controller Schema Controller instance.
+ */
+ public function __construct( ExtendSchema $extend, SchemaController $controller ) {
+ parent::__construct( $extend, $controller );
+ $schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
+ if ( ! $schema instanceof ImageAttachmentSchema ) {
+ throw new \RuntimeException( 'ImageAttachmentSchema is not registered in SchemaController.' );
+ }
+ $this->image_attachment_schema = $schema;
+ }
+
+ /**
+ * Item schema properties.
+ *
+ * @return array
+ */
+ public function get_properties() {
+ return array(
+ 'key' => array(
+ 'description' => __( 'Stable identifier for the saved item within its list.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'id' => array(
+ 'description' => __( 'Variation ID if applicable, otherwise product ID.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'product_id' => array(
+ 'description' => __( 'Product ID at the time the item was saved.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'variation_id' => array(
+ 'description' => __( 'Variation ID at the time the item was saved, or 0 for non-variable products.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'quantity' => array(
+ 'description' => __( 'Quantity of this saved item.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'is_live' => array(
+ 'description' => __( 'True when the row serves live product data; false rows are at-save tombstones.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'is_purchasable' => array(
+ 'description' => __( 'True when the product can be added to the cart.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Product name. Live when is_live is true; falls back to the at-save title snapshot otherwise.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'permalink' => array(
+ 'description' => __( 'Product URL. Null when the row is a tombstone (so iAPI strips the anchor href).', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'images' => array(
+ 'description' => __( 'List of images for the live product. Empty when the product no longer exists.', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => $this->image_attachment_schema->get_properties(),
+ ),
+ ),
+ 'variation' => array(
+ 'description' => __( 'Chosen variation attributes, if applicable.', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'raw_attribute' => array(
+ 'description' => __( 'Variation system generated attribute name.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'attribute' => array(
+ 'description' => __( 'Variation attribute name.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'value' => array(
+ 'description' => __( 'Variation attribute value.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ ),
+ 'prices' => array(
+ 'description' => __( 'Live product prices. Omitted when the product no longer exists.', 'woocommerce' ),
+ 'type' => array( 'object', 'null' ),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'properties' => array_merge(
+ $this->get_store_currency_properties(),
+ array(
+ 'price' => array(
+ 'description' => __( 'Current product price.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'regular_price' => array(
+ 'description' => __( 'Regular product price.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'sale_price' => array(
+ 'description' => __( 'Sale product price, if applicable.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ )
+ ),
+ ),
+ 'price_html' => array(
+ 'description' => __( 'Live product price as HTML, formatted via wc_price including sale/discount markup. Empty when the product no longer exists.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'image_html' => array(
+ 'description' => __( 'Product thumbnail as a fully-formed <img> element with srcset, sizes, alt, and lazy-loading attributes. Falls back to the configured placeholder image when the product has no image or no longer exists.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'date_added_gmt' => array(
+ 'description' => __( 'The date the item was saved, as GMT.', 'woocommerce' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ );
+ }
+
+ /**
+ * Serialize the saved item.
+ *
+ * @param ShopperListItem $item Saved item.
+ * @return array
+ */
+ public function get_item_response( $item ) {
+ $variation_id = $item->get_variation_id();
+ $product_id = $variation_id > 0 ? $variation_id : $item->get_product_id();
+ $product = $item->get_product();
+ $is_live = $item->is_live();
+
+ $response = array(
+ 'key' => $item->get_key(),
+ 'id' => $product_id,
+ 'product_id' => $item->get_product_id(),
+ 'variation_id' => $variation_id,
+ 'quantity' => $item->get_quantity(),
+ 'is_live' => $is_live,
+ 'is_purchasable' => $item->is_purchasable(),
+ 'date_added_gmt' => wc_rest_prepare_date_response( $item->get_date_added_gmt() ),
+ );
+
+ if ( $is_live && $product instanceof \WC_Product ) {
+ $response['name'] = $this->get_name( $product );
+ $response['permalink'] = $product->get_permalink();
+ $response['images'] = $this->get_images( $product );
+ $response['variation'] = $this->format_variation_data( $item->get_variation_attributes(), $product );
+ $response['prices'] = (object) $this->get_prices( $product );
+ $response['price_html'] = (string) $product->get_price_html();
+ $response['image_html'] = $this->get_image_html( $product );
+ } else {
+ $response['name'] = $this->prepare_html_response( $item->get_product_title_at_save() );
+ $response['permalink'] = null;
+ $response['images'] = array();
+ $response['variation'] = array();
+ $response['prices'] = null;
+ $response['price_html'] = '';
+ $response['image_html'] = $this->get_image_html( null );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get the displayable name for the live product.
+ *
+ * @param \WC_Product $product Live product instance.
+ * @return string
+ */
+ private function get_name( \WC_Product $product ): string {
+ $prepared = $this->prepare_html_response( $product->get_title() );
+ return is_string( $prepared ) ? $prepared : (string) $product->get_title();
+ }
+
+ /**
+ * Get the main image for a shopper list item.
+ *
+ * Returns the product's main image only — shopper list rows are compact and
+ * the gallery isn't needed at the row level.
+ *
+ * @param \WC_Product $product Live product instance.
+ * @return array
+ */
+ private function get_images( \WC_Product $product ): array {
+ $image_id = (int) $product->get_image_id();
+ if ( $image_id <= 0 ) {
+ return array();
+ }
+
+ $image = $this->image_attachment_schema->get_item_response( $image_id );
+ return $image ? array( $image ) : array();
+ }
+
+ /**
+ * Get the thumbnail image HTML for a shopper list item, falling back to the
+ * WooCommerce placeholder when the product has no image or has been deleted.
+ *
+ * Pre-formatting on the server lets renderers (PHP SSR + JS hydration)
+ * consume one canonical string instead of each side composing the markup
+ * from the structured `images` array. Mirrors the pattern WC uses in
+ * `ProductSchema::price_html` / `ProductImage::render`.
+ *
+ * @param \WC_Product|null $product Live product instance, or null for tombstones.
+ * @return string
+ */
+ private function get_image_html( ?\WC_Product $product ): string {
+ $image_id = $product instanceof \WC_Product ? (int) $product->get_image_id() : 0;
+ if ( $image_id > 0 ) {
+ return (string) wp_get_attachment_image( $image_id, 'woocommerce_thumbnail' );
+ }
+ return (string) wc_placeholder_img( 'woocommerce_thumbnail' );
+ }
+
+ /**
+ * Compute live prices for the saved item.
+ *
+ * We don't extend ProductSchema because saved items aren't products. The shape
+ * here is a thin subset of cart-item prices.
+ *
+ * @param \WC_Product $product Live product instance.
+ * @return array
+ */
+ private function get_prices( \WC_Product $product ): array {
+ $decimals = wc_get_price_decimals();
+ $regular_price = $product->get_regular_price();
+ $sale_price = $product->get_sale_price();
+ $current_price = $product->get_price();
+
+ return $this->prepare_currency_response(
+ array(
+ 'price' => $this->prepare_money_response( $current_price, $decimals ),
+ 'regular_price' => $this->prepare_money_response( '' === $regular_price ? $current_price : $regular_price, $decimals ),
+ 'sale_price' => '' === $sale_price ? '' : $this->prepare_money_response( $sale_price, $decimals ),
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListSchema.php
new file mode 100644
index 00000000000..60352ce2982
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListSchema.php
@@ -0,0 +1,128 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+
+/**
+ * ShopperListSchema class.
+ *
+ * Represents a single shopper list, including its saved items.
+ */
+class ShopperListSchema extends AbstractSchema {
+ /**
+ * The schema item name.
+ *
+ * @var string
+ */
+ protected $title = 'shopper_list';
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'shopper-list';
+
+ /**
+ * Item schema instance.
+ *
+ * @var ShopperListItemSchema
+ */
+ protected $item_schema;
+
+ /**
+ * Constructor.
+ *
+ * @throws \RuntimeException When the ShopperListItemSchema is not registered.
+ *
+ * @param ExtendSchema $extend Rest Extending instance.
+ * @param SchemaController $controller Schema Controller instance.
+ */
+ public function __construct( ExtendSchema $extend, SchemaController $controller ) {
+ parent::__construct( $extend, $controller );
+ $schema = $this->controller->get( ShopperListItemSchema::IDENTIFIER );
+ if ( ! $schema instanceof ShopperListItemSchema ) {
+ throw new \RuntimeException( 'ShopperListItemSchema is not registered in SchemaController.' );
+ }
+ $this->item_schema = $schema;
+ }
+
+ /**
+ * Schema properties.
+ *
+ * @return array
+ */
+ public function get_properties() {
+ return array(
+ 'slug' => array(
+ 'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'date_created_gmt' => array(
+ 'description' => __( 'The date the list was created, as GMT.', 'woocommerce' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'item_count' => array(
+ 'description' => __( 'Number of items currently in the list.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'items' => array(
+ 'description' => __( 'List of saved items.', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => $this->force_schema_readonly( $this->item_schema->get_properties() ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Serialize the shopper list.
+ *
+ * @param ShopperList $shopper_list The list.
+ * @return array
+ */
+ public function get_item_response( $shopper_list ) {
+ $items = array_values( $shopper_list->get_items() );
+
+ $product_ids = array_filter(
+ array_map(
+ static function ( ShopperListItem $item ): int {
+ $variation_id = $item->get_variation_id();
+ return $variation_id > 0 ? $variation_id : $item->get_product_id();
+ },
+ $items
+ )
+ );
+ if ( ! empty( $product_ids ) ) {
+ _prime_post_caches( array_unique( $product_ids ) );
+ }
+
+ return array(
+ 'slug' => $shopper_list->get_slug(),
+ 'date_created_gmt' => wc_rest_prepare_date_response( $shopper_list->get_date_created_gmt() ),
+ 'item_count' => count( $items ),
+ 'items' => array_values(
+ array_map(
+ fn( ShopperListItem $item ) => $this->item_schema->get_item_response( $item ),
+ $items
+ )
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html
index 705b43a5cd8..fb3866329db 100644
--- a/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html
@@ -1,3 +1,4 @@
<!-- wp:group {"layout":{"type":"flex","flexWrap":"nowrap"}} -->
<div class="wp-block-group"><!-- wp:woocommerce/product-button {"textAlign":"left"} /--></div>
<!-- /wp:group -->
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html
index 5e296b3d1e6..60b5b28b271 100644
--- a/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html
@@ -31,3 +31,4 @@
<!-- wp:group {"layout":{"type":"flex","flexWrap":"nowrap"}} -->
<div class="wp-block-group"><!-- wp:woocommerce/product-button {"textAlign":"left"} /--></div>
<!-- /wp:group -->
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html
index 0a6d6b24f33..9eafe827d77 100644
--- a/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html
@@ -3,3 +3,4 @@
<div class="wp-block-group"><!-- wp:woocommerce/add-to-cart-with-options-quantity-selector {"quantitySelectorStyle":"stepper"} /-->
<!-- wp:woocommerce/product-button {"textAlign":"left"} /--></div>
<!-- /wp:group -->
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
index f9eb81d8eb0..f6f09c10bff 100644
--- a/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
@@ -36,3 +36,5 @@
<!-- wp:woocommerce/product-button {"textAlign":"left"} /-->
</div>
<!-- /wp:group -->
+
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
index 5b03b6c1e85..2663359c8f4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
@@ -3,6 +3,7 @@
namespace Automattic\WooCommerce\Tests\Blocks\Assets;
use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
use Automattic\WooCommerce\Blocks\Package;
use InvalidArgumentException;
@@ -86,4 +87,42 @@ class AssetDataRegistry extends \WP_UnitTestCase {
$this->assertEquals( $original_data, $data );
remove_filter( 'woocommerce_shared_settings', [ self::class, 'ndcallback' ] );
}
+
+ /**
+ * @testdox `experimentalCartSaveForLater` is registered as true when the `cart_save_for_later` feature is enabled.
+ */
+ public function test_experimental_cart_save_for_later_setting_is_true_when_feature_enabled() {
+ $features_controller = wc_get_container()->get( FeaturesController::class );
+ $original_enabled = $features_controller->feature_is_enabled( 'cart_save_for_later' );
+
+ $features_controller->change_feature_enable( 'cart_save_for_later', true );
+ try {
+ $this->registry->initialize_core_data();
+ $data = $this->registry->get();
+
+ $this->assertArrayHasKey( 'experimentalCartSaveForLater', $data );
+ $this->assertTrue( $data['experimentalCartSaveForLater'] );
+ } finally {
+ $features_controller->change_feature_enable( 'cart_save_for_later', $original_enabled );
+ }
+ }
+
+ /**
+ * @testdox `experimentalCartSaveForLater` is registered as false when the `cart_save_for_later` feature is disabled.
+ */
+ public function test_experimental_cart_save_for_later_setting_is_false_when_feature_disabled() {
+ $features_controller = wc_get_container()->get( FeaturesController::class );
+ $original_enabled = $features_controller->feature_is_enabled( 'cart_save_for_later' );
+
+ $features_controller->change_feature_enable( 'cart_save_for_later', false );
+ try {
+ $this->registry->initialize_core_data();
+ $data = $this->registry->get();
+
+ $this->assertArrayHasKey( 'experimentalCartSaveForLater', $data );
+ $this->assertFalse( $data['experimentalCartSaveForLater'] );
+ } finally {
+ $features_controller->change_feature_enable( 'cart_save_for_later', $original_enabled );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToWishlistButtonTests.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToWishlistButtonTests.php
new file mode 100644
index 00000000000..edb2ee3ca23
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToWishlistButtonTests.php
@@ -0,0 +1,296 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Blocks\BlockTypes\AddToWishlistButton;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionProperty;
+use stdClass;
+use WP_Block_Supports;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the AddToWishlistButton block type.
+ */
+class AddToWishlistButtonTests extends WP_UnitTestCase {
+
+ /**
+ * System under test, instantiated without invoking the constructor so the
+ * test bootstrap's existing block registration isn't re-applied. The
+ * methods under test only read `$this->namespace` / `$this->block_name`
+ * (class defaults) plus an injected `asset_data_registry`.
+ *
+ * @var AddToWishlistButton
+ */
+ private AddToWishlistButton $sut;
+
+ /**
+ * Cached for restoration in tearDown — render() temporarily sets it so
+ * `get_block_wrapper_attributes()` can resolve layout/style supports.
+ *
+ * @var array|null
+ */
+ private ?array $previous_block_to_render = null;
+
+ /**
+ * Instantiate the block without invoking its constructor and inject the
+ * registry mock so render() can call its registered helpers without
+ * NPEing.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $reflection = new ReflectionClass( AddToWishlistButton::class );
+ $this->sut = $reflection->newInstanceWithoutConstructor();
+
+ $registry_prop = new ReflectionProperty( AddToWishlistButton::class, 'asset_data_registry' );
+ $registry_prop->setAccessible( true );
+ $registry_prop->setValue(
+ $this->sut,
+ new AssetDataRegistryMock( Package::container()->get( Api::class ) )
+ );
+ }
+
+ /**
+ * Reset any global state we mutated for `get_block_wrapper_attributes()`.
+ */
+ public function tearDown(): void {
+ if ( null !== $this->previous_block_to_render ) {
+ WP_Block_Supports::$block_to_render = $this->previous_block_to_render;
+ $this->previous_block_to_render = null;
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * Build a minimal $block stub carrying a `context` array — enough for
+ * `render()` to read `$block->context['postId']`. A `stdClass` is
+ * sufficient because the renderer only ever reads via property access.
+ *
+ * @param int|null $post_id Post ID to seed (or null for the "no context" case).
+ * @return stdClass
+ */
+ private function build_block_stub( ?int $post_id ): stdClass {
+ $block = new stdClass();
+ $block->context = null === $post_id ? array() : array( 'postId' => $post_id );
+ return $block;
+ }
+
+ /**
+ * Call the protected `render()` method via reflection. Sets
+ * `WP_Block_Supports::$block_to_render` so `get_block_wrapper_attributes()`
+ * inside render() has the context it expects when invoked outside the
+ * normal block-render pipeline.
+ *
+ * @param stdClass|null $block Block stub.
+ * @return string Rendered markup.
+ */
+ private function invoke_render( ?stdClass $block ): string {
+ $attributes = array();
+
+ $this->previous_block_to_render = WP_Block_Supports::$block_to_render;
+ WP_Block_Supports::$block_to_render = array(
+ 'blockName' => 'woocommerce/add-to-wishlist-button',
+ 'attrs' => $attributes,
+ );
+
+ $render = new ReflectionMethod( AddToWishlistButton::class, 'render' );
+ $render->setAccessible( true );
+
+ return (string) $render->invoke( $this->sut, $attributes, '', $block );
+ }
+
+ /**
+ * `render()` returns an empty string for logged-out shoppers, before any
+ * product/context lookups happen.
+ */
+ public function test_render_returns_empty_for_logged_out_user(): void {
+ wp_set_current_user( 0 );
+
+ $this->assertSame(
+ '',
+ $this->invoke_render( $this->build_block_stub( 12345 ) ),
+ 'Guests must never see the wishlist trigger.'
+ );
+ }
+
+ /**
+ * `render()` returns an empty string when `$block->context` has no
+ * `postId` — the button has no product to act on.
+ */
+ public function test_render_returns_empty_when_no_post_id_in_context(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ $this->assertSame( '', $this->invoke_render( $this->build_block_stub( null ) ) );
+ }
+
+ /**
+ * `render()` returns an empty string when the supplied `postId` doesn't
+ * resolve to a `WC_Product` (e.g. the post was deleted, or it's a non-
+ * product post type).
+ */
+ public function test_render_returns_empty_when_product_cannot_be_loaded(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ // 999999 is intentionally a non-existent post ID.
+ $this->assertSame( '', $this->invoke_render( $this->build_block_stub( 999999 ) ) );
+ }
+
+ /**
+ * For a logged-in shopper on a simple product not in the wishlist, the
+ * rendered markup carries the iAPI wrapper, the per-block context (with
+ * `isVariableType` false and `isPending` false), and the empty-star
+ * initial state with `aria-pressed="false"`.
+ */
+ public function test_render_simple_product_not_in_wishlist(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ $product = \WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+
+ $markup = $this->invoke_render( $this->build_block_stub( $product_id ) );
+
+ $this->assertStringContainsString(
+ 'data-wp-interactive="woocommerce/add-to-wishlist-button"',
+ $markup,
+ 'Wrapper must declare its iAPI interactive scope.'
+ );
+
+ // `data-wp-context` is HTML-encoded; the embedded quotes appear as `"`.
+ $this->assertStringContainsString( '"productId":' . $product_id, $markup );
+ $this->assertStringContainsString( '"isVariableType":false', $markup );
+ $this->assertStringContainsString( '"isPending":false', $markup );
+
+ $this->assertStringContainsString(
+ 'aria-pressed="false"',
+ $markup,
+ 'Simple product not in the wishlist starts with aria-pressed=false.'
+ );
+ $this->assertStringNotContainsString(
+ ' disabled',
+ $markup,
+ 'Simple products are always actionable — never disabled on first paint.'
+ );
+ }
+
+ /**
+ * For a variable product, the button must be `disabled` on first paint
+ * and show the "Select options first" label, since no variation has been
+ * selected yet.
+ */
+ public function test_render_variable_product_is_disabled_with_select_options_label(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ $product = \WC_Helper_Product::create_variation_product();
+ $product_id = $product->get_id();
+
+ $markup = $this->invoke_render( $this->build_block_stub( $product_id ) );
+
+ $this->assertStringContainsString( '"isVariableType":true', $markup );
+
+ $this->assertMatchesRegularExpression(
+ '/<button[^>]*\bdisabled\b/',
+ $markup,
+ 'Variable product without a selected variation must render the button disabled.'
+ );
+
+ $this->assertStringContainsString(
+ 'Select options first',
+ $markup,
+ 'Initial label for variable products is "Select options first".'
+ );
+ }
+
+ /**
+ * The hidden empty-star span and the (initially hidden) filled-star span
+ * are both present, so iAPI can toggle visibility without DOM swaps.
+ */
+ public function test_render_emits_both_star_icons(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ $product = \WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+
+ $markup = $this->invoke_render( $this->build_block_stub( $product_id ) );
+
+ $this->assertStringContainsString( 'wc-block-add-to-wishlist-button__icon--empty', $markup );
+ $this->assertStringContainsString( 'wc-block-add-to-wishlist-button__icon--filled', $markup );
+
+ // Both spans are bound to `state.isInWishlist` (one positive, one
+ // negated) so iAPI can toggle without removing/re-adding nodes.
+ $this->assertStringContainsString( 'data-wp-bind--hidden="state.isInWishlist"', $markup );
+ $this->assertStringContainsString( 'data-wp-bind--hidden="!state.isInWishlist"', $markup );
+ }
+
+ /**
+ * `is_initial_in_wishlist()` is the small helper that decides whether the
+ * SSR-rendered button starts in the "saved" state. Unit-testable in
+ * isolation: pass a synthetic prefetched-items array plus a product and
+ * assert the boolean result without touching the REST layer.
+ *
+ * @return array<string, array{array<int, array<string, mixed>>, string, bool}>
+ */
+ public function provider_is_initial_in_wishlist(): array {
+ return array(
+ 'simple in list' => array(
+ array( array( 'id' => 42 ) ),
+ 'simple',
+ true,
+ ),
+ 'simple not in list' => array(
+ array( array( 'id' => 99 ) ),
+ 'simple',
+ false,
+ ),
+ 'simple with empty list' => array(
+ array(),
+ 'simple',
+ false,
+ ),
+ 'variable parent ignores entries' => array(
+ array( array( 'id' => 42 ) ),
+ 'variable',
+ false,
+ ),
+ );
+ }
+
+ /**
+ * @dataProvider provider_is_initial_in_wishlist
+ *
+ * @param array<int, array<string, mixed>> $items Schema-shape items from the prefetch.
+ * @param string $type Product type to construct ("simple" or "variable").
+ * @param bool $expected Expected boolean result.
+ */
+ public function test_is_initial_in_wishlist( array $items, string $type, bool $expected ): void {
+ $product = 'variable' === $type
+ ? \WC_Helper_Product::create_variation_product()
+ : \WC_Helper_Product::create_simple_product();
+
+ // Re-key the synthetic items to use this product's real ID for the
+ // "in list" cases, so the test data stays independent of the
+ // auto-generated post IDs the factory hands out.
+ $normalized = array();
+ foreach ( $items as $item ) {
+ if ( isset( $item['id'] ) && 42 === $item['id'] ) {
+ $item['id'] = $product->get_id();
+ }
+ $normalized[] = $item;
+ }
+
+ $method = new ReflectionMethod( AddToWishlistButton::class, 'is_initial_in_wishlist' );
+ $method->setAccessible( true );
+
+ $this->assertSame( $expected, $method->invoke( $this->sut, $normalized, $product ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
new file mode 100644
index 00000000000..8465c94e246
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
@@ -0,0 +1,404 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Blocks\BlockTypes\SavedForLater;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionProperty;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the SavedForLater block type.
+ */
+class SavedForLaterTests extends WP_UnitTestCase {
+
+ /**
+ * System under test.
+ *
+ * Constructed via reflection so `AbstractBlock::__construct` doesn't run
+ * `parent::initialize()` and re-register the block (the test bootstrap
+ * has already registered it). The filter callbacks under test only read
+ * `$this->namespace` and `$this->block_name`, both class defaults.
+ *
+ * @var SavedForLater
+ */
+ private SavedForLater $sut;
+
+ /**
+ * Instantiate the block without invoking its constructor and inject a
+ * registry mock so render() can call `->add()` without NPEing.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $reflection = new ReflectionClass( SavedForLater::class );
+ $this->sut = $reflection->newInstanceWithoutConstructor();
+
+ $registry_prop = new ReflectionProperty( SavedForLater::class, 'asset_data_registry' );
+ $registry_prop->setAccessible( true );
+ $registry_prop->setValue(
+ $this->sut,
+ new AssetDataRegistryMock( Package::container()->get( Api::class ) )
+ );
+ }
+
+ /**
+ * @return array<string, array{string, string, bool, bool}>
+ */
+ public function provider_register_hooked_block(): array {
+ $cart_only = '<!-- wp:woocommerce/cart /-->';
+ $cart_with_block = '<!-- wp:woocommerce/cart /--><!-- wp:woocommerce/saved-for-later /-->';
+
+ return array(
+ // label => array( cart_page_content, anchor, context_is_cart_page, expected_hooked ).
+ 'hooked after cart on cart page' => array( $cart_only, 'woocommerce/cart', true, true ),
+ 'not hooked after non-cart anchor' => array( $cart_only, 'core/paragraph', true, false ),
+ 'not hooked when context is other page' => array( $cart_only, 'woocommerce/cart', false, false ),
+ 'not hooked when already present' => array( $cart_with_block, 'woocommerce/cart', true, false ),
+ );
+ }
+
+ /**
+ * `register_hooked_block` only adds the block when the anchor is `woocommerce/cart`,
+ * the context is the cart page, and the cart page doesn't already contain the block.
+ *
+ * @dataProvider provider_register_hooked_block
+ *
+ * @param string $cart_page_content Initial content of the cart page.
+ * @param string $anchor Anchor block name passed to the filter.
+ * @param bool $context_is_cart_page Whether the filter context is the cart page or some other page.
+ * @param bool $expected_hooked Whether the block should end up in the hooked list.
+ */
+ public function test_register_hooked_block( string $cart_page_content, string $anchor, bool $context_is_cart_page, bool $expected_hooked ): void {
+ $cart_page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_content' => $cart_page_content,
+ )
+ );
+ update_option( 'woocommerce_cart_page_id', $cart_page_id );
+
+ $context_id = $context_is_cart_page
+ ? $cart_page_id
+ : self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $hooked = $this->sut->register_hooked_block( array(), 'after', $anchor, get_post( $context_id ) );
+
+ if ( $expected_hooked ) {
+ $this->assertContains( 'woocommerce/saved-for-later', $hooked );
+ } else {
+ $this->assertNotContains( 'woocommerce/saved-for-later', $hooked );
+ }
+ }
+
+ /**
+ * When the cart page option is unset, `wc_get_page_id()` returns -1 — the filter
+ * must treat that as "no cart page" rather than letting it match a real post ID.
+ */
+ public function test_register_hooked_block_skips_when_cart_page_unset(): void {
+ delete_option( 'woocommerce_cart_page_id' );
+
+ $context_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_content' => '<!-- wp:woocommerce/cart /-->',
+ )
+ );
+
+ $hooked = $this->sut->register_hooked_block( array(), 'after', 'woocommerce/cart', get_post( $context_id ) );
+
+ $this->assertNotContains( 'woocommerce/saved-for-later', $hooked );
+ }
+
+ /**
+ * The auto-injected block ships with a seeded `core/heading` inner block so
+ * fresh cart pages render the heading on the frontend out of the box. The
+ * matching `null` push onto `innerContent` is what makes `WP_Block::render()`
+ * walk into the heading when building `$content`.
+ */
+ public function test_hooked_block_attributes_seed_heading_inner_block(): void {
+ $parsed_hooked_block = array(
+ 'blockName' => 'woocommerce/saved-for-later',
+ 'attrs' => array(),
+ );
+ $parsed_anchor_block = array( 'blockName' => 'woocommerce/cart' );
+
+ $result = $this->sut->set_hooked_block_attributes(
+ $parsed_hooked_block,
+ 'woocommerce/saved-for-later',
+ 'after',
+ $parsed_anchor_block
+ );
+
+ $this->assertArrayHasKey( 'innerBlocks', $result );
+ $this->assertCount( 1, $result['innerBlocks'] );
+
+ $heading = $result['innerBlocks'][0];
+ $this->assertSame( 'core/heading', $heading['blockName'] );
+ $this->assertSame( 2, $heading['attrs']['level'] );
+ $this->assertArrayHasKey( 'content', $heading['attrs'] );
+ // `attrs.content` is the raw translated string (no `esc_html`) —
+ // JSON encoding handles escaping at serialization time. Asserts
+ // the en_US source string the test bootstrap runs under.
+ $this->assertSame( 'Saved for later', $heading['attrs']['content'] );
+ $this->assertStringContainsString( '<h2 class="wp-block-heading">', $heading['innerHTML'] );
+ $this->assertSame( array( $heading['innerHTML'] ), $heading['innerContent'] );
+
+ $this->assertArrayHasKey( 'innerContent', $result );
+ $this->assertContains( null, $result['innerContent'] );
+ }
+
+ /**
+ * Extensions are free to hook `hooked_block_woocommerce/saved-for-later`
+ * to add their own inner blocks at a different priority. Our heading must
+ * still be seeded alongside, not in place of, what they added.
+ */
+ public function test_hooked_block_attributes_appends_heading_alongside_existing_inner_blocks(): void {
+ $existing_block = array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '<p>From another extension</p>',
+ 'innerContent' => array( '<p>From another extension</p>' ),
+ );
+ $parsed_hooked_block = array(
+ 'blockName' => 'woocommerce/saved-for-later',
+ 'attrs' => array(),
+ 'innerBlocks' => array( $existing_block ),
+ 'innerContent' => array( null ),
+ );
+ $parsed_anchor_block = array( 'blockName' => 'woocommerce/cart' );
+
+ $result = $this->sut->set_hooked_block_attributes(
+ $parsed_hooked_block,
+ 'woocommerce/saved-for-later',
+ 'after',
+ $parsed_anchor_block
+ );
+
+ $this->assertCount( 2, $result['innerBlocks'] );
+ // The other-extension block is preserved at its original index.
+ $this->assertSame( $existing_block, $result['innerBlocks'][0] );
+ // Our heading is appended after it.
+ $this->assertSame( 'core/heading', $result['innerBlocks'][1]['blockName'] );
+ $this->assertSame( 'Saved for later', $result['innerBlocks'][1]['attrs']['content'] );
+ // Parent innerContent gains a `null` placeholder for each inner block,
+ // so `WP_Block::render()` walks into both when building `$content`.
+ $this->assertCount( 2, $result['innerContent'] );
+ }
+
+ /**
+ * `render()` returns an empty string for logged-out shoppers.
+ */
+ public function test_render_returns_empty_for_logged_out_user(): void {
+ wp_set_current_user( 0 );
+
+ $render = new ReflectionMethod( SavedForLater::class, 'render' );
+ $render->setAccessible( true );
+
+ $this->assertSame( '', (string) $render->invoke( $this->sut, array(), '', null ) );
+ }
+
+ /**
+ * For a logged-in shopper whose list is empty (the new-shopper /
+ * never-saved-an-item case), SSR must:
+ * - emit the empty-state `<li>` already `hidden`, so the message
+ * never flashes between paint and iAPI hydration, and
+ * - seed the wrapper's iAPI context with `hasShownItems: false` and
+ * the matching `data-wp-watch` callback, so the JS-side
+ * `state.isEmpty` getter has the inputs it needs to keep the
+ * message hidden until the shopper has actually saved an item.
+ *
+ * With no saved items, `prefetch_items()` returns `[]` whether
+ * the Store API route is registered or not (a 404 still resolves to
+ * an empty array), so this stays a unit-level assertion without
+ * feature-flag wiring or fixture items. Sets
+ * `WP_Block_Supports::$block_to_render` up front so
+ * `get_block_wrapper_attributes()` (which reads it for layout/style
+ * supports) has the context it expects when called outside the
+ * usual block-render pipeline.
+ */
+ public function test_render_seeds_hidden_empty_state_for_new_shopper(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ $attributes = array();
+
+ $previous_block_to_render = \WP_Block_Supports::$block_to_render;
+ \WP_Block_Supports::$block_to_render = array(
+ 'blockName' => 'woocommerce/saved-for-later',
+ 'attrs' => $attributes,
+ );
+
+ try {
+ $render = new ReflectionMethod( SavedForLater::class, 'render' );
+ $render->setAccessible( true );
+
+ $markup = (string) $render->invoke( $this->sut, $attributes, '', null );
+ } finally {
+ \WP_Block_Supports::$block_to_render = $previous_block_to_render;
+ }
+
+ // The empty-state `<li>` is always rendered, always initially hidden.
+ $this->assertMatchesRegularExpression(
+ '/<li[^>]*class="wc-block-saved-for-later__empty"[^>]*\bhidden\b/',
+ $markup,
+ 'Empty-state <li> must be initially hidden so the message does not flash before iAPI hydration.'
+ );
+
+ // The wrapper's `data-wp-context` JSON is HTML-escaped into an
+ // attribute, so the embedded quotes appear as `"` in the
+ // rendered markup.
+ $this->assertStringContainsString(
+ '"hasShownItems":false',
+ $markup,
+ 'Wrapper context must seed hasShownItems=false for an empty list so the empty message stays hidden until the shopper actually saves an item.'
+ );
+
+ $this->assertStringContainsString(
+ 'data-wp-watch="callbacks.trackShownItems"',
+ $markup,
+ 'Wrapper must wire the trackShownItems watcher so hasShownItems can flip to true the first time items appear in-session.'
+ );
+ }
+
+ /**
+ * The seeded heading (and any future sibling inner blocks rendered via
+ * `$content`) must share the empty-state visibility gating: hidden on
+ * first paint for new shoppers / empty refreshes, revealed once the
+ * iAPI watcher flips `context.hasShownItems`. Without this, a saved
+ * cart page rendered with no items would show an orphaned heading
+ * sitting above nothing.
+ */
+ public function test_render_wraps_header_with_hidden_visibility_gate_when_empty(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $customer_id );
+
+ $attributes = array();
+
+ $previous_block_to_render = \WP_Block_Supports::$block_to_render;
+ \WP_Block_Supports::$block_to_render = array(
+ 'blockName' => 'woocommerce/saved-for-later',
+ 'attrs' => $attributes,
+ );
+
+ try {
+ $render = new ReflectionMethod( SavedForLater::class, 'render' );
+ $render->setAccessible( true );
+
+ $content = '<h2 class="wp-block-heading">Saved for later</h2>';
+ $markup = (string) $render->invoke( $this->sut, $attributes, $content, null );
+ } finally {
+ \WP_Block_Supports::$block_to_render = $previous_block_to_render;
+ }
+
+ // The header wrapper exists, contains the heading, has the iAPI
+ // visibility bind, and is initially hidden because items is empty.
+ $this->assertMatchesRegularExpression(
+ '/<div[^>]*class="wc-block-saved-for-later__header"[^>]*data-wp-bind--hidden="!context\.hasShownItems"[^>]*\bhidden\b[^>]*>.*Saved for later/s',
+ $markup,
+ 'Header wrapper must be initially hidden for an empty list so a fresh-load empty SC does not show an orphaned heading.'
+ );
+ }
+
+ /**
+ * Invoke render() against the SUT (which already has a registry injected
+ * in setUp). Render is wrapped in a try/catch because its downstream calls
+ * (REST prefetch, interactivity bootstrap) need bits of the request
+ * lifecycle that aren't set up in unit tests; the flag-setting branch
+ * runs before any of that, so a later fatal doesn't change what we
+ * assert on.
+ *
+ * @param array<string, mixed> $attributes Block attributes.
+ * @return AssetDataRegistryMock The injected registry, ready to inspect.
+ */
+ private function invoke_render_with_registry_mock( array $attributes ): AssetDataRegistryMock {
+ $render = new ReflectionMethod( SavedForLater::class, 'render' );
+ $render->setAccessible( true );
+
+ try {
+ $render->invoke( $this->sut, $attributes, '', null );
+ } catch ( \Throwable $e ) {
+ // Ignored: flag-setting runs before the parts of render() that need a full request lifecycle.
+ unset( $e );
+ }
+
+ $registry_prop = new ReflectionProperty( SavedForLater::class, 'asset_data_registry' );
+ $registry_prop->setAccessible( true );
+
+ return $registry_prop->getValue( $this->sut );
+ }
+
+ /**
+ * @return array<string, array{bool, bool, bool}>
+ */
+ public function provider_cart_page_has_saved_for_later_flag(): array {
+ return array(
+ // label => array( logged_in, is_cart, expected ).
+ 'set on cart, logged-in' => array( true, true, true ),
+ 'not set off the cart page' => array( true, false, false ),
+ 'not set for guests' => array( false, true, false ),
+ );
+ }
+
+ /**
+ * `cartPageHasSavedForLater` is the wcSettings flag the cart line item row reads
+ * to decide whether to render the "Save for later" link. The block sets it
+ * only when rendering the saved-for-later list, on the cart page, for a
+ * logged-in shopper — every other combination must leave it unset.
+ *
+ * @dataProvider provider_cart_page_has_saved_for_later_flag
+ *
+ * @param bool $logged_in Whether the test runs as a logged-in customer.
+ * @param bool $is_cart Whether `is_cart()` is forced to return true.
+ * @param bool $expected Whether the flag is expected to be registered.
+ */
+ public function test_cart_page_has_saved_for_later_flag(
+ bool $logged_in,
+ bool $is_cart,
+ bool $expected
+ ): void {
+ wp_set_current_user( $logged_in ? self::factory()->user->create( array( 'role' => 'customer' ) ) : 0 );
+
+ // Mock the is_cart() call routed through LegacyProxy in render(). Filter/cache
+ // approaches don't work in CI: upstream tests can define `WOOCOMMERCE_CART` via
+ // `wc_maybe_define_constant`, which makes is_cart() short-circuit to true
+ // irreversibly for the rest of the process.
+ $legacy_proxy = wc_get_container()->get( LegacyProxy::class );
+ $legacy_proxy->reset();
+ $legacy_proxy->register_function_mocks(
+ array(
+ 'is_cart' => function () use ( $is_cart ) {
+ return $is_cart;
+ },
+ )
+ );
+
+ // This class extends WP_UnitTestCase rather than WC_Unit_Test_Case, so the
+ // proxy isn't reset automatically between tests — clean up explicitly to
+ // avoid leaking the is_cart() mock into later tests in the same process.
+ try {
+ $registry = $this->invoke_render_with_registry_mock( array() );
+
+ $this->assertSame(
+ $expected,
+ array_key_exists( 'cartPageHasSavedForLater', $registry->get() )
+ );
+ } finally {
+ $legacy_proxy->reset();
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
new file mode 100644
index 00000000000..530e565f389
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
@@ -0,0 +1,434 @@
+<?php
+/**
+ * Shopper Lists Route Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+
+/**
+ * Tests for the /wc/store/v1/shopper-lists/* endpoints.
+ */
+class ShopperLists extends ControllerTestCase {
+
+ /**
+ * Test product.
+ *
+ * @var \WC_Product
+ */
+ private $product;
+
+ /**
+ * Test customer user ID.
+ *
+ * @var int
+ */
+ private $customer_id;
+
+ /**
+ * Second test customer user ID, used to verify cross-user isolation.
+ *
+ * @var int
+ */
+ private $other_customer_id;
+
+ /**
+ * Setup test data.
+ */
+ protected function setUp(): void {
+ // The shopper-lists routes are gated behind the `cart_save_for_later`
+ // feature flag, which is read inside `do_action( 'rest_api_init' )`
+ // fired by parent::setUp(). The option must be in place before then.
+ update_option( 'woocommerce_cart_save_for_later_enabled', 'yes' );
+
+ parent::setUp();
+
+ $fixtures = new FixtureData();
+ $this->product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ $this->customer_id = $this->factory->user->create(
+ array(
+ 'role' => 'customer',
+ 'user_email' => 'shopper-lists-1@test.com',
+ )
+ );
+ $this->other_customer_id = $this->factory->user->create(
+ array(
+ 'role' => 'customer',
+ 'user_email' => 'shopper-lists-2@test.com',
+ )
+ );
+ }
+
+ /**
+ * Tear down test data.
+ */
+ protected function tearDown(): void {
+ parent::tearDown();
+
+ if ( $this->customer_id ) {
+ wp_delete_user( $this->customer_id );
+ }
+ if ( $this->other_customer_id ) {
+ wp_delete_user( $this->other_customer_id );
+ }
+
+ delete_option( 'woocommerce_cart_save_for_later_enabled' );
+ }
+
+ /**
+ * Helper: dispatch a request and return the response.
+ *
+ * On writes, `$nonce` defaults to a valid `wc_store_api` nonce. Pass `''`
+ * to omit the header, or any other string to send a bad one.
+ *
+ * @param string $method HTTP method.
+ * @param string $route Route path.
+ * @param array $params Body params.
+ * @param string|null $nonce Nonce override.
+ * @return \WP_REST_Response
+ */
+ private function dispatch( string $method, string $route, array $params = array(), ?string $nonce = null ): \WP_REST_Response {
+ $request = new \WP_REST_Request( $method, $route );
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $is_write = in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
+ if ( $is_write ) {
+ if ( null === $nonce ) {
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ } elseif ( '' !== $nonce ) {
+ $request->set_header( 'Nonce', $nonce );
+ }
+ }
+
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * Helper: add the test product to the cart and return its cart_item_key.
+ *
+ * @return string
+ */
+ private function add_product_to_cart(): string {
+ $key = wc()->cart->add_to_cart( $this->product->get_id(), 1 );
+ $this->assertNotEmpty( $key, 'add_to_cart should return a non-empty cart item key.' );
+ return (string) $key;
+ }
+
+ /**
+ * Test that an unauthenticated request to GET /shopper-lists is rejected.
+ */
+ public function test_get_lists_requires_login() {
+ wp_set_current_user( 0 );
+
+ $response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists' );
+
+ $this->assertContains( $response->get_status(), array( 401, 403 ), 'Unauthenticated requests must be rejected by the permission callback.' );
+ }
+
+ /**
+ * Test that a logged-in user starts with saved-for-later auto-created and empty.
+ */
+ public function test_get_lists_returns_save_for_later_for_logged_in_user() {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists' );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertIsArray( $data );
+ $this->assertCount( 1, $data, 'Only saved-for-later is returned in v1.' );
+ $this->assertSame( 'saved-for-later', $data[0]['slug'] );
+ $this->assertSame( 0, $data[0]['item_count'] );
+ }
+
+ /**
+ * Test that GET /shopper-lists/saved-for-later returns the list metadata.
+ */
+ public function test_get_list_by_id_returns_save_for_later_metadata() {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later' );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertSame( 'saved-for-later', $data['slug'] );
+ }
+
+ /**
+ * Test that GET /shopper-lists/{slug} returns 404 for any list other than saved-for-later.
+ */
+ public function test_get_list_by_id_returns_404_for_unsupported_list() {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/wishlist' );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test POST /shopper-lists/saved-for-later/items with a real cart_item_key returns the saved item.
+ */
+ public function test_post_item_via_cart_item_key() {
+ wp_set_current_user( $this->customer_id );
+
+ $cart_item_key = $this->add_product_to_cart();
+
+ $response = $this->dispatch(
+ 'POST',
+ '/wc/store/v1/shopper-lists/saved-for-later/items',
+ array( 'cart_item_key' => $cart_item_key )
+ );
+ $data = $response->get_data();
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertSame( $this->product->get_id(), $data['product_id'] );
+ $this->assertSame( 1, $data['quantity'], 'Saved quantity should mirror the cart line quantity.' );
+ $this->assertTrue( $data['is_live'] );
+ $this->assertSame( $this->product->get_title(), $data['name'] );
+ $this->assertNotEmpty( wc()->cart->cart_contents, 'Cart should still contain the line — POST is additive only.' );
+ }
+
+ /**
+ * Test POST rejects requests without cart_item_key or product_id.
+ */
+ public function test_post_item_requires_cart_item_key_or_product_id() {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Test POST /shopper-lists/saved-for-later/items via direct product payload returns the saved item.
+ */
+ public function test_post_item_via_manual_product_payload() {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch(
+ 'POST',
+ '/wc/store/v1/shopper-lists/saved-for-later/items',
+ array(
+ 'product_id' => $this->product->get_id(),
+ 'quantity' => 2,
+ )
+ );
+ $data = $response->get_data();
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertSame( $this->product->get_id(), $data['product_id'] );
+ $this->assertSame( 2, $data['quantity'], 'Posted quantity should be honored.' );
+ }
+
+ /**
+ * Test POST rejects an unknown cart_item_key.
+ */
+ public function test_post_item_unknown_cart_item_key_returns_404() {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch(
+ 'POST',
+ '/wc/store/v1/shopper-lists/saved-for-later/items',
+ array( 'cart_item_key' => 'thiskeydoesnotexist' )
+ );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test POST returns 404 for any slug other than saved-for-later.
+ */
+ public function test_post_item_unsupported_slug_returns_404() {
+ wp_set_current_user( $this->customer_id );
+ $cart_item_key = $this->add_product_to_cart();
+
+ $response = $this->dispatch(
+ 'POST',
+ '/wc/store/v1/shopper-lists/wishlist/items',
+ array( 'cart_item_key' => $cart_item_key )
+ );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test that adding the same cart line twice merges quantities into a single row.
+ */
+ public function test_post_item_is_idempotent_for_same_cart_line() {
+ wp_set_current_user( $this->customer_id );
+ $cart_item_key = $this->add_product_to_cart();
+
+ $first = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+ $second = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+
+ $this->assertEquals( 201, $first->get_status() );
+ $this->assertEquals( 201, $second->get_status() );
+ $this->assertSame( $first->get_data()['key'], $second->get_data()['key'], 'Same cart line should resolve to the same item key.' );
+ $this->assertSame( 2, $second->get_data()['quantity'], 'Repeating the same cart line must merge quantities.' );
+
+ $items_response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+ $this->assertCount( 1, $items_response->get_data(), 'Same cart line should not produce a duplicate row.' );
+ }
+
+ /**
+ * Test that GET /items returns the saved items.
+ */
+ public function test_get_items_returns_saved_items() {
+ wp_set_current_user( $this->customer_id );
+ $cart_item_key = $this->add_product_to_cart();
+
+ $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+
+ $response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $response->get_data() );
+ }
+
+ /**
+ * Test that DELETE removes the item and returns 204 No Content.
+ */
+ public function test_delete_item_removes_item() {
+ wp_set_current_user( $this->customer_id );
+ $cart_item_key = $this->add_product_to_cart();
+
+ $created = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+ $key = $created->get_data()['key'];
+
+ $response = $this->dispatch( 'DELETE', '/wc/store/v1/shopper-lists/saved-for-later/items/' . $key );
+
+ $this->assertEquals( 204, $response->get_status() );
+ $this->assertNull( $response->get_data() );
+
+ $items_response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+ $this->assertCount( 0, $items_response->get_data(), 'List should be empty after the only item is deleted.' );
+ }
+
+ /**
+ * Test that DELETE returns 404 when the item does not exist.
+ */
+ public function test_delete_item_unknown_returns_404() {
+ wp_set_current_user( $this->customer_id );
+
+ // Auto-create the list so the route reaches the item-lookup branch.
+ $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later' );
+
+ $nonexistent_key = str_repeat( 'a', 32 );
+ $response = $this->dispatch( 'DELETE', '/wc/store/v1/shopper-lists/saved-for-later/items/' . $nonexistent_key );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test that a logged-out user cannot delete via the route.
+ */
+ public function test_delete_item_requires_login() {
+ wp_set_current_user( 0 );
+
+ $nonexistent_key = str_repeat( 'a', 32 );
+ $response = $this->dispatch( 'DELETE', '/wc/store/v1/shopper-lists/saved-for-later/items/' . $nonexistent_key );
+
+ $this->assertContains( $response->get_status(), array( 401, 403 ) );
+ }
+
+ /**
+ * Test that one user cannot see another user's items.
+ */
+ public function test_users_lists_are_isolated() {
+ wp_set_current_user( $this->customer_id );
+ $cart_item_key = $this->add_product_to_cart();
+ $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+
+ wp_set_current_user( $this->other_customer_id );
+ $response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 0, $response->get_data(), 'Other user should not see the first user\'s items.' );
+ }
+
+ /**
+ * Test writes without (or with invalid) Nonce header are rejected.
+ *
+ * @testWith ["POST", "", 401, "woocommerce_rest_missing_nonce"]
+ * ["POST", "not-a-valid-nonce", 403, "woocommerce_rest_invalid_nonce"]
+ * ["DELETE", "", 401, "woocommerce_rest_missing_nonce"]
+ * ["DELETE", "not-a-valid-nonce", 403, "woocommerce_rest_invalid_nonce"]
+ *
+ * @param string $method HTTP method.
+ * @param string $nonce Nonce header value.
+ * @param int $expected_status Expected HTTP status code.
+ * @param string $expected_error_code Expected WP_Error code.
+ */
+ public function test_write_nonce_enforcement( string $method, string $nonce, int $expected_status, string $expected_error_code ) {
+ wp_set_current_user( $this->customer_id );
+
+ $is_post = 'POST' === $method;
+ $path = $is_post
+ ? '/wc/store/v1/shopper-lists/saved-for-later/items'
+ : '/wc/store/v1/shopper-lists/saved-for-later/items/' . str_repeat( 'a', 32 );
+ $params = $is_post ? array( 'product_id' => $this->product->get_id() ) : array();
+
+ $response = $this->dispatch( $method, $path, $params, $nonce );
+
+ $this->assertEquals( $expected_status, $response->get_status() );
+ $this->assertSame( $expected_error_code, $response->get_data()['code'] );
+ }
+
+ /**
+ * Test every response (success or auth failure) refreshes the Nonce headers.
+ *
+ * @testWith [null, 201]
+ * ["", 401]
+ *
+ * @param string|null $nonce Nonce header value, or null to auto-attach a valid one.
+ * @param int $expected_status Expected HTTP status code.
+ */
+ public function test_response_refreshes_nonce_headers( ?string $nonce, int $expected_status ) {
+ wp_set_current_user( $this->customer_id );
+
+ $response = $this->dispatch(
+ 'POST',
+ '/wc/store/v1/shopper-lists/saved-for-later/items',
+ array( 'product_id' => $this->product->get_id() ),
+ $nonce
+ );
+ $headers = $response->get_headers();
+
+ $this->assertEquals( $expected_status, $response->get_status() );
+ $this->assertArrayHasKey( 'Nonce', $headers );
+ $this->assertArrayHasKey( 'Nonce-Timestamp', $headers );
+ $this->assertTrue( (bool) wp_verify_nonce( $headers['Nonce'], 'wc_store_api' ) );
+ }
+
+ /**
+ * Test the `woocommerce_store_api_disable_nonce_check` filter bypass.
+ */
+ public function test_disable_nonce_check_filter_bypasses_enforcement() {
+ wp_set_current_user( $this->customer_id );
+
+ add_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' );
+ try {
+ $response = $this->dispatch(
+ 'POST',
+ '/wc/store/v1/shopper-lists/saved-for-later/items',
+ array( 'product_id' => $this->product->get_id() ),
+ ''
+ );
+ } finally {
+ remove_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' );
+ }
+
+ $this->assertEquals( 201, $response->get_status() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
new file mode 100644
index 00000000000..89d9406675a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
@@ -0,0 +1,496 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Schemas;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListItemSchema;
+use WC_Unit_Test_Case;
+
+/**
+ * ShopperListItemSchemaTest class.
+ */
+class ShopperListItemSchemaTest extends WC_Unit_Test_Case {
+ /**
+ * The System Under Test.
+ *
+ * @var ShopperListItemSchema
+ */
+ private $sut;
+
+ /**
+ * ExtendSchema instance.
+ *
+ * @var ExtendSchema
+ */
+ private $extend;
+
+ /**
+ * SchemaController instance.
+ *
+ * @var SchemaController
+ */
+ private $schema_controller;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $formatters = new Formatters();
+ $formatters->register( 'money', MoneyFormatter::class );
+ $formatters->register( 'html', HtmlFormatter::class );
+ $formatters->register( 'currency', CurrencyFormatter::class );
+
+ $this->extend = new ExtendSchema( $formatters );
+ $this->schema_controller = new SchemaController( $this->extend );
+ $this->sut = new ShopperListItemSchema( $this->extend, $this->schema_controller );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ $this->sut = null;
+ $this->extend = null;
+ $this->schema_controller = null;
+ }
+
+ /**
+ * Build a ShopperListItem around a product.
+ *
+ * @param int $product_id Product ID.
+ * @param int $variation_id Variation ID, or 0.
+ * @param array $variation Variation attributes.
+ * @param string $title Title snapshot.
+ */
+ private function build_item( int $product_id, int $variation_id = 0, array $variation = array(), string $title = 'Snapshot Title' ): ShopperListItem {
+ return ShopperListItem::from_array(
+ array(
+ 'key' => md5( (string) $product_id ),
+ 'product_id' => $product_id,
+ 'variation_id' => $variation_id,
+ 'variation' => $variation,
+ 'quantity' => 1,
+ 'date_added_gmt' => '2024-04-25 03:20:00',
+ 'product_title_at_save' => $title,
+ )
+ );
+ }
+
+ /**
+ * @testdox Should serve live product data and is_live=true for a published product.
+ */
+ public function test_returns_live_data_when_is_live(): void {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Live T-Shirt',
+ 'regular_price' => 19.99,
+ )
+ );
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id(), 0, array(), 'Snapshot T-Shirt' ) );
+
+ $this->assertTrue( $response['is_live'], 'is_live must be true for a published product' );
+ $this->assertTrue( $response['is_purchasable'], 'A published, in-stock priced product is purchasable' );
+ $this->assertSame( 'Live T-Shirt', $response['name'], 'Live name should be served, not the snapshot' );
+ $this->assertSame( $product->get_permalink(), $response['permalink'] );
+ $this->assertNotNull( $response['prices'], 'Live prices should be populated' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox is_live should gate on publish status (self and parent). Stock, catalog visibility, and post password don't affect it.
+ * @dataProvider provider_is_live_cases
+ *
+ * @param array<string, string> $overrides Post/product overrides to apply.
+ * @param bool $expected_live Expected is_live value.
+ */
+ public function test_is_live_predicate( array $overrides, bool $expected_live ): void {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Subject',
+ 'regular_price' => 19.99,
+ )
+ );
+
+ if ( isset( $overrides['stock_status'] ) ) {
+ $product->set_stock_status( $overrides['stock_status'] );
+ $product->save();
+ }
+ if ( isset( $overrides['catalog_visibility'] ) ) {
+ $product->set_catalog_visibility( $overrides['catalog_visibility'] );
+ $product->save();
+ }
+ $post_overrides = array_intersect_key( $overrides, array_flip( array( 'post_status', 'post_password' ) ) );
+ if ( ! empty( $post_overrides ) ) {
+ // `wp_update_post` silently rewrites `future` back to `publish` when post_date is in the past,
+ // so a future date is needed to actually persist the status.
+ if ( 'future' === ( $post_overrides['post_status'] ?? '' ) ) {
+ $post_overrides['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', strtotime( '+1 year' ) );
+ $post_overrides['post_date'] = $post_overrides['post_date_gmt'];
+ }
+ wp_update_post( array_merge( array( 'ID' => $product->get_id() ), $post_overrides ) );
+ }
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id(), 0, array(), 'Snapshot Title' ) );
+
+ $this->assertSame( $expected_live, $response['is_live'] );
+ if ( ! $expected_live ) {
+ $this->assertFalse( $response['is_purchasable'], 'Non-public products are not purchasable' );
+ $this->assertSame( 'Snapshot Title', $response['name'], 'Tombstone must not leak the live title' );
+ $this->assertNull( $response['permalink'], 'Tombstone permalink must be null so iAPI strips the anchor href' );
+ $this->assertNull( $response['prices'], 'Tombstone must not leak live prices' );
+ }
+
+ wp_delete_post( $product->get_id(), true );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, string>, 1: bool}>
+ */
+ public function provider_is_live_cases(): array {
+ return array(
+ // Renderable — guards against using `is_visible()`, which would
+ // tombstone deliberately-saved OOS / catalog-hidden items.
+ 'OOS, publish' => array( array( 'stock_status' => 'outofstock' ), true ),
+ 'catalog_visibility=hidden' => array( array( 'catalog_visibility' => 'hidden' ), true ),
+ // Tombstone cases.
+ 'draft' => array( array( 'post_status' => 'draft' ), false ),
+ 'pending' => array( array( 'post_status' => 'pending' ), false ),
+ 'private' => array( array( 'post_status' => 'private' ), false ),
+ 'trash' => array( array( 'post_status' => 'trash' ), false ),
+ 'future' => array( array( 'post_status' => 'future' ), false ),
+ );
+ }
+
+ /**
+ * @testdox Out-of-stock products stay live but aren't purchasable, matching catalog behavior.
+ */
+ public function test_out_of_stock_product_is_live_but_not_purchasable(): void {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'OOS T-Shirt',
+ 'regular_price' => 19.99,
+ )
+ );
+ $product->set_stock_status( 'outofstock' );
+ $product->save();
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ $this->assertTrue( $response['is_live'], 'OOS products stay renderable' );
+ $this->assertFalse( $response['is_purchasable'], 'Cart button hidden for OOS, mirroring the storefront catalog gate (is_purchasable() && is_in_stock())' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Password-protected products stay live (clickable) but aren't purchasable.
+ */
+ public function test_password_protected_product_is_live_but_not_purchasable(): void {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Gated T-Shirt',
+ 'regular_price' => 19.99,
+ )
+ );
+ wp_update_post(
+ array(
+ 'ID' => $product->get_id(),
+ 'post_password' => 'secret',
+ )
+ );
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ $this->assertTrue( $response['is_live'], 'Page renders behind a password prompt, so the row stays clickable' );
+ $this->assertFalse( $response['is_purchasable'], 'Customer must authenticate before purchasing' );
+ $this->assertSame( $product->get_permalink(), $response['permalink'] );
+
+ wp_delete_post( $product->get_id(), true );
+ }
+
+ /**
+ * @testdox Admins viewing their own draft products see a tombstone, not a Move-to-cart button.
+ */
+ public function test_admin_viewing_own_draft_does_not_short_circuit_is_purchasable(): void {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin_id );
+
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Pre-launch T-Shirt',
+ 'regular_price' => 19.99,
+ )
+ );
+ wp_update_post(
+ array(
+ 'ID' => $product->get_id(),
+ 'post_status' => 'draft',
+ )
+ );
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ // WC_Product::is_purchasable() short-circuits via `current_user_can( 'edit_post', ... )`
+ // for admins. Our predicate must gate on is_live first so the tombstone row isn't
+ // rendered with a working cart button.
+ $this->assertFalse( $response['is_live'] );
+ $this->assertFalse( $response['is_purchasable'], 'Admin escape hatch in WC_Product::is_purchasable() must not leak into is_purchasable on a tombstone row.' );
+
+ wp_delete_post( $product->get_id(), true );
+ wp_delete_user( $admin_id );
+ }
+
+ /**
+ * @testdox Should tombstone a variation whose parent is not published.
+ */
+ public function test_variation_with_non_publish_parent_is_tombstoned(): void {
+ $variable = \WC_Helper_Product::create_variation_product();
+ $children = $variable->get_children();
+ $this->assertNotEmpty( $children, 'Variable product helper should produce variation children' );
+ $variation_id = (int) $children[0];
+
+ wp_update_post(
+ array(
+ 'ID' => $variable->get_id(),
+ 'post_status' => 'draft',
+ )
+ );
+
+ $response = $this->sut->get_item_response(
+ $this->build_item( $variable->get_id(), $variation_id, array(), 'Snapshot Title' )
+ );
+
+ $this->assertFalse( $response['is_live'], 'Variations under a non-publish parent must be tombstoned' );
+ $this->assertSame( 'Snapshot Title', $response['name'] );
+
+ $variable->delete( true );
+ }
+
+ /**
+ * @testdox Variations under a password-protected parent stay live but aren't purchasable.
+ */
+ public function test_variation_with_password_protected_parent_is_not_purchasable(): void {
+ $variable = \WC_Helper_Product::create_variation_product();
+ $children = $variable->get_children();
+ $this->assertNotEmpty( $children, 'Variable product helper should produce variation children' );
+ $variation_id = (int) $children[0];
+
+ wp_update_post(
+ array(
+ 'ID' => $variable->get_id(),
+ 'post_password' => 'secret',
+ )
+ );
+
+ $response = $this->sut->get_item_response(
+ $this->build_item( $variable->get_id(), $variation_id, array(), 'Snapshot Title' )
+ );
+
+ $this->assertTrue( $response['is_live'], 'Parent still renders behind a password prompt' );
+ $this->assertFalse( $response['is_purchasable'], 'Parent password must gate the variation too' );
+
+ $variable->delete( true );
+ }
+
+ /**
+ * @testdox Should expose price_html for live products and an empty string for tombstones.
+ */
+ public function test_price_html_is_populated_for_live_products(): void {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Priced T-Shirt',
+ 'regular_price' => 19.99,
+ )
+ );
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ $this->assertArrayHasKey( 'price_html', $response, 'price_html must be present on the response' );
+ $this->assertIsString( $response['price_html'] );
+ $this->assertNotSame( '', $response['price_html'], 'price_html must be non-empty for a live priced product' );
+ $this->assertStringContainsString( 'woocommerce-Price-amount', $response['price_html'], 'price_html should be the formatted markup from wc_price' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should expose image_html with the configured product thumbnail when one is set.
+ */
+ public function test_image_html_uses_product_thumbnail_when_available(): void {
+ $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Imaged T-Shirt' ) );
+ $product->set_image_id( $attachment_id );
+ $product->save();
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ $this->assertArrayHasKey( 'image_html', $response, 'image_html must be present on the response' );
+ $this->assertIsString( $response['image_html'] );
+ $this->assertStringContainsString( '<img', $response['image_html'], 'image_html must be a fully-formed <img> element' );
+ $this->assertStringContainsString( 'srcset=', $response['image_html'], 'image_html must carry the responsive srcset attribute' );
+
+ $product->delete( true );
+ wp_delete_attachment( $attachment_id, true );
+ }
+
+ /**
+ * @testdox Should expose image_html using the WooCommerce placeholder when the product has no image.
+ */
+ public function test_image_html_falls_back_to_placeholder_when_product_has_no_image(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'No-Image T-Shirt' ) );
+ $product->set_image_id( 0 );
+ $product->save();
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ $this->assertArrayHasKey( 'image_html', $response );
+ $this->assertSame(
+ (string) wc_placeholder_img( 'woocommerce_thumbnail' ),
+ $response['image_html'],
+ 'image_html for an image-less product must equal the configured placeholder markup'
+ );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should fall back to at-save snapshot data when the product no longer exists.
+ */
+ public function test_falls_back_to_snapshot_when_product_missing(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'About to be Deleted' ) );
+ $product_id = $product->get_id();
+ $item = $this->build_item( $product_id, 0, array(), 'Snapshot Title' );
+ wp_delete_post( $product_id, true );
+
+ $response = $this->sut->get_item_response( $item );
+
+ $this->assertFalse( $response['is_live'], 'is_live must be false when the product is gone' );
+ $this->assertSame( 'Snapshot Title', $response['name'], 'Tombstone name should fall back to the at-save title snapshot' );
+ $this->assertNull( $response['permalink'], 'Tombstone permalink must be null so iAPI strips the anchor href' );
+ $this->assertSame( array(), $response['images'], 'No images should be returned for missing products' );
+ $this->assertNull( $response['prices'], 'Live prices should be null for missing products' );
+ $this->assertArrayNotHasKey( 'product_title_at_save', $response, 'Internal at-save title snapshot should not leak into the public response' );
+ }
+
+ /**
+ * @testdox Should expose an empty price_html and the placeholder image_html for tombstones.
+ */
+ public function test_tombstone_returns_empty_price_html_and_placeholder_image_html(): void {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Going Away',
+ 'regular_price' => 24.99,
+ )
+ );
+ $product_id = $product->get_id();
+ $item = $this->build_item( $product_id, 0, array(), 'Going Away' );
+ wp_delete_post( $product_id, true );
+
+ $response = $this->sut->get_item_response( $item );
+
+ $this->assertArrayHasKey( 'price_html', $response );
+ $this->assertSame( '', $response['price_html'], 'Tombstones must not advertise a price' );
+ $this->assertArrayHasKey( 'image_html', $response );
+ $this->assertSame(
+ (string) wc_placeholder_img( 'woocommerce_thumbnail' ),
+ $response['image_html'],
+ 'Tombstones must use the placeholder image markup'
+ );
+ }
+
+ /**
+ * @testdox Should expose key on a live-product response.
+ */
+ public function test_response_carries_the_item_key(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $item = $this->build_item( $product->get_id() );
+
+ $response = $this->sut->get_item_response( $item );
+
+ $this->assertArrayHasKey( 'key', $response );
+ $this->assertSame( $item->get_key(), $response['key'], 'Response key must match the saved item key.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should format date_added_gmt via wc_rest_prepare_date_response (ISO8601, no timezone suffix).
+ */
+ public function test_date_added_gmt_is_iso8601(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+ $this->assertArrayHasKey( 'date_added_gmt', $response );
+ $this->assertSame( '2024-04-25T03:20:00', $response['date_added_gmt'], 'date_added_gmt must be the ISO8601 formatting of the GMT save time.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should expose the variation ID as id when the saved item is a variation.
+ */
+ public function test_id_resolves_to_variation_id_for_variations(): void {
+ $variable_product = \WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable_product->get_children();
+ $variation_id = (int) $variation_ids[0];
+ $item = $this->build_item( $variable_product->get_id(), $variation_id );
+
+ $response = $this->sut->get_item_response( $item );
+
+ $this->assertArrayHasKey( 'id', $response );
+ $this->assertSame( $variation_id, $response['id'], 'id should resolve to variation_id when set.' );
+ $this->assertArrayHasKey( 'product_id', $response );
+ $this->assertSame( $variable_product->get_id(), $response['product_id'], 'product_id should still hold the parent product id.' );
+ $this->assertArrayHasKey( 'variation_id', $response );
+ $this->assertSame( $variation_id, $response['variation_id'] );
+
+ $variable_product->delete( true );
+ }
+
+ /**
+ * @testdox Should format saved variation attributes via format_variation_data on live variations.
+ */
+ public function test_variation_attributes_are_formatted_on_live_variations(): void {
+ $variable_product = \WC_Helper_Product::create_variation_product();
+ $variation_id = (int) $variable_product->get_children()[0];
+ $item = $this->build_item(
+ $variable_product->get_id(),
+ $variation_id,
+ array( 'attribute_pa_size' => 'small' )
+ );
+
+ $response = $this->sut->get_item_response( $item );
+
+ $this->assertArrayHasKey( 'variation', $response );
+ $this->assertIsArray( $response['variation'] );
+ $this->assertCount( 1, $response['variation'], 'A single saved attribute should produce one entry in the variation list.' );
+
+ $entry = $response['variation'][0];
+ $this->assertArrayHasKey( 'raw_attribute', $entry );
+ $this->assertArrayHasKey( 'attribute', $entry );
+ $this->assertArrayHasKey( 'value', $entry );
+ $this->assertSame( 'attribute_pa_size', $entry['raw_attribute'] );
+
+ $variable_product->delete( true );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListSchemaTest.php
new file mode 100644
index 00000000000..d878769c999
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListSchemaTest.php
@@ -0,0 +1,175 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Schemas;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListSchema;
+use WC_Unit_Test_Case;
+
+/**
+ * ShopperListSchemaTest class.
+ */
+class ShopperListSchemaTest extends WC_Unit_Test_Case {
+ /**
+ * The System Under Test.
+ *
+ * @var ShopperListSchema
+ */
+ private $sut;
+
+ /**
+ * Test user ID.
+ *
+ * @var int
+ */
+ private $user_id;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ // `saved-for-later` depends on the `cart_save_for_later` feature
+ // flag. Filter the option read so `ShopperList::get_by_slug()`
+ // returns a list without writing the option to the database.
+ add_filter( 'pre_option_woocommerce_cart_save_for_later_enabled', array( $this, 'filter_save_for_later_enabled' ) );
+
+ parent::setUp();
+
+ $formatters = new Formatters();
+ $formatters->register( 'money', MoneyFormatter::class );
+ $formatters->register( 'html', HtmlFormatter::class );
+ $formatters->register( 'currency', CurrencyFormatter::class );
+
+ $extend = new ExtendSchema( $formatters );
+ $schema_controller = new SchemaController( $extend );
+ $this->sut = new ShopperListSchema( $extend, $schema_controller );
+ $this->user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ wp_delete_user( $this->user_id );
+ $this->sut = null;
+ remove_filter( 'pre_option_woocommerce_cart_save_for_later_enabled', array( $this, 'filter_save_for_later_enabled' ) );
+ parent::tearDown();
+ }
+
+ /**
+ * Filter callback that forces the SFL option to `yes` for the lifetime of the test.
+ */
+ public function filter_save_for_later_enabled(): string {
+ return 'yes';
+ }
+
+ /**
+ * Build an empty saved-for-later list for the test user.
+ */
+ private function build_list(): ShopperList {
+ return ShopperList::get_by_slug( 'saved-for-later', $this->user_id );
+ }
+
+ /**
+ * Build a minimal ShopperListItem around a product ID.
+ *
+ * @param int $product_id Product ID.
+ */
+ private function build_item( int $product_id ): ShopperListItem {
+ return ShopperListItem::from_array(
+ array(
+ 'key' => md5( (string) $product_id ),
+ 'product_id' => $product_id,
+ 'variation_id' => 0,
+ 'variation' => array(),
+ 'quantity' => 1,
+ 'date_added_gmt' => '2024-04-25 03:20:00',
+ 'product_title_at_save' => 'Snapshot',
+ )
+ );
+ }
+
+ /**
+ * @testdox Should expose slug, item_count, date_created_gmt and a 0-indexed items array.
+ */
+ public function test_serializes_top_level_fields(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $list = $this->build_list();
+ $list->add_item( $this->build_item( $product->get_id() ) );
+
+ $response = $this->sut->get_item_response( $list );
+
+ $this->assertArrayHasKey( 'slug', $response );
+ $this->assertSame( 'saved-for-later', $response['slug'] );
+
+ $this->assertArrayHasKey( 'item_count', $response );
+ $this->assertSame( 1, $response['item_count'] );
+
+ $this->assertArrayHasKey( 'items', $response );
+ $this->assertIsArray( $response['items'] );
+ $this->assertSame( array( 0 ), array_keys( $response['items'] ), 'items must be a 0-indexed list, not keyed by storage key.' );
+
+ $this->assertArrayHasKey( 'date_created_gmt', $response );
+ $this->assertIsString( $response['date_created_gmt'] );
+ $this->assertNotSame( '', $response['date_created_gmt'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox item_count should include items whose products no longer exist.
+ */
+ public function test_item_count_includes_tombstones(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $list = $this->build_list();
+ $list->add_item( $this->build_item( $product->get_id() ) );
+ $list->add_item( $this->build_item( 999999 ) );
+
+ $response = $this->sut->get_item_response( $list );
+
+ $this->assertSame( 2, $response['item_count'], 'item_count must count tombstoned items, not just live products.' );
+ $this->assertCount( 2, $response['items'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Empty list should serialize with item_count=0 and items=[].
+ */
+ public function test_empty_list(): void {
+ $list = $this->build_list();
+
+ $response = $this->sut->get_item_response( $list );
+
+ $this->assertSame( 0, $response['item_count'] );
+ $this->assertSame( array(), $response['items'] );
+ }
+
+ /**
+ * @testdox Each item should carry the keys produced by the item schema.
+ */
+ public function test_items_use_item_schema_shape(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $list = $this->build_list();
+ $list->add_item( $this->build_item( $product->get_id() ) );
+
+ $response = $this->sut->get_item_response( $list );
+
+ $item = $response['items'][0];
+ $this->assertArrayHasKey( 'key', $item );
+ $this->assertArrayHasKey( 'product_id', $item );
+ $this->assertArrayHasKey( 'quantity', $item );
+ $this->assertArrayHasKey( 'is_live', $item );
+ $this->assertTrue( $item['is_live'], 'Live product items should have is_live=true.' );
+
+ $product->delete( true );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListItemTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListItemTests.php
new file mode 100644
index 00000000000..4f1479dc6e9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListItemTests.php
@@ -0,0 +1,153 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for ShopperListItem.
+ */
+class ShopperListItemTests extends WC_Unit_Test_Case {
+ /**
+ * @var \WC_Product
+ */
+ private $product;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Item SUT Product',
+ 'regular_price' => 19.99,
+ )
+ );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ if ( $this->product ) {
+ $this->product->delete( true );
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox from_product should build an item from a live product, snapshotting the title.
+ */
+ public function test_from_product_builds_item_from_live_product(): void {
+ $item = ShopperListItem::from_product( $this->product->get_id(), array(), 3 );
+
+ $this->assertInstanceOf( ShopperListItem::class, $item );
+ $arr = $item->to_array();
+ $this->assertSame( $this->product->get_title(), $arr['product_title_at_save'] );
+ $this->assertSame( 3, $arr['quantity'], 'Quantity should reflect the value passed to from_product.' );
+ }
+
+ /**
+ * @testdox from_product should default quantity to 1 and coerce zero/negative values up to 1.
+ */
+ public function test_from_product_normalizes_quantity_floor(): void {
+ $default = ShopperListItem::from_product( $this->product->get_id() );
+ $this->assertInstanceOf( ShopperListItem::class, $default );
+ $this->assertSame( 1, $default->to_array()['quantity'] );
+
+ $zero = ShopperListItem::from_product( $this->product->get_id(), array(), 0 );
+ $this->assertInstanceOf( ShopperListItem::class, $zero );
+ $this->assertSame( 1, $zero->to_array()['quantity'] );
+
+ $negative = ShopperListItem::from_product( $this->product->get_id(), array(), -5 );
+ $this->assertInstanceOf( ShopperListItem::class, $negative );
+ $this->assertSame( 1, $negative->to_array()['quantity'] );
+ }
+
+ /**
+ * @testdox from_product should return null when the product can't be resolved.
+ */
+ public function test_from_product_returns_null_for_missing_product(): void {
+ $this->assertNull( ShopperListItem::from_product( 99999999 ) );
+ }
+
+ /**
+ * @testdox to_array round-trips through from_array.
+ */
+ public function test_round_trips_through_from_array(): void {
+ $original = ShopperListItem::from_product( $this->product->get_id() );
+ $rebuilt = ShopperListItem::from_array( $original->to_array() );
+
+ $this->assertSame( $original->to_array(), $rebuilt->to_array() );
+ }
+
+ /**
+ * @testdox from_product validates the variation array against the variation product, like cart does.
+ */
+ public function test_from_variation_validates_against_variation_product(): void {
+ $variable = \WC_Helper_Product::create_variation_product();
+
+ $find = function ( array $attrs ) use ( $variable ): int {
+ foreach ( $variable->get_children() as $variation_id ) {
+ $expected = wc_get_product_variation_attributes( (int) $variation_id );
+ if ( empty( array_diff_assoc( $attrs, $expected ) ) ) {
+ return (int) $variation_id;
+ }
+ }
+ $this->fail( 'No variation matched the requested attribute set.' );
+ };
+
+ $all_specific = $find(
+ array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_colour' => 'red',
+ 'attribute_pa_number' => '0',
+ )
+ );
+ $any_number = $find(
+ array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_colour' => 'blue',
+ 'attribute_pa_number' => '',
+ )
+ );
+
+ // Specific attrs: server fills them in even when the caller passes nothing.
+ $variation = ShopperListItem::from_product( $all_specific, array() )->to_array()['variation'];
+ $this->assertSame( 'huge', $variation['attribute_pa_size'] );
+ $this->assertSame( 'red', $variation['attribute_pa_colour'] );
+ $this->assertSame( '0', $variation['attribute_pa_number'] );
+
+ // Specific attrs: client value mismatching the variation is rejected.
+ try {
+ ShopperListItem::from_product( $all_specific, array( 'attribute_pa_colour' => 'blue' ) );
+ $this->fail( 'Expected mismatched specific value to throw.' );
+ } catch ( \InvalidArgumentException $e ) {
+ $this->addToAssertionCount( 1 );
+ }
+
+ // "Any" slot: missing client value is rejected.
+ try {
+ ShopperListItem::from_product( $any_number, array() );
+ $this->fail( 'Expected missing any-slot value to throw.' );
+ } catch ( \InvalidArgumentException $e ) {
+ $this->addToAssertionCount( 1 );
+ }
+
+ // "Any" slot: a value present on the parent is accepted and stored.
+ $variation = ShopperListItem::from_product( $any_number, array( 'attribute_pa_number' => '2' ) )->to_array()['variation'];
+ $this->assertSame( '2', $variation['attribute_pa_number'] );
+
+ // "Any" slot: a value not in the parent's slugs is rejected.
+ try {
+ ShopperListItem::from_product( $any_number, array( 'attribute_pa_number' => '99' ) );
+ $this->fail( 'Expected invalid any-slot value to throw.' );
+ } catch ( \InvalidArgumentException $e ) {
+ $this->addToAssertionCount( 1 );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListTests.php
new file mode 100644
index 00000000000..7f59e91010c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListTests.php
@@ -0,0 +1,200 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for ShopperList.
+ */
+class ShopperListTests extends WC_Unit_Test_Case {
+ private const SAVED_FOR_LATER_SLUG = 'saved-for-later';
+
+ /**
+ * Map of shopper-list slug => feature option key.
+ */
+ private const LIST_OPTIONS = array(
+ 'saved-for-later' => 'woocommerce_cart_save_for_later_enabled',
+ 'wishlist' => 'woocommerce_product_wishlist_enabled',
+ );
+
+ /**
+ * @var int
+ */
+ private $user_id;
+
+ /**
+ * @var \WC_Product
+ */
+ private $product;
+
+ /**
+ * @var ShopperListItem
+ */
+ private $item;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->enable_list( self::SAVED_FOR_LATER_SLUG );
+
+ $this->user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+ $this->product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'List SUT Product',
+ 'regular_price' => 19.99,
+ )
+ );
+ $this->item = ShopperListItem::from_product( $this->product->get_id() );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ if ( $this->product ) {
+ $this->product->delete( true );
+ }
+ foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+ $this->disable_list( $slug );
+ }
+ delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+ parent::tearDown();
+ }
+
+ /**
+ * Enable the feature backing the given shopper-list slug.
+ *
+ * @param string $slug List slug.
+ */
+ private function enable_list( string $slug ): void {
+ update_option( self::LIST_OPTIONS[ $slug ], 'yes' );
+ }
+
+ /**
+ * Disable the feature backing the given shopper-list slug.
+ *
+ * @param string $slug List slug.
+ */
+ private function disable_list( string $slug ): void {
+ update_option( self::LIST_OPTIONS[ $slug ], 'no' );
+ }
+
+ /**
+ * @testdox saved-for-later is in-memory only on first read and is persisted lazily on the first add_item()+save().
+ */
+ public function test_save_for_later_persistence_is_lazy(): void {
+ $list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+ $this->assertInstanceOf( ShopperList::class, $list );
+ $this->assertSame( self::SAVED_FOR_LATER_SLUG, $list->get_slug() );
+ $this->assertSame( array(), $list->get_items() );
+
+ $meta_key = ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG;
+ $this->assertSame( '', Users::get_site_user_meta( $this->user_id, $meta_key ), 'Empty saved-for-later should not be persisted before the first add.' );
+
+ $list->add_item( $this->item );
+ $list->save();
+
+ $this->assertIsArray( Users::get_site_user_meta( $this->user_id, $meta_key ), 'saved-for-later should be persisted after the first add+save.' );
+ }
+
+ /**
+ * @testdox get_by_slug should return false for any disabled or unknown list slug.
+ */
+ public function test_load_returns_false_for_unsupported_list_slug(): void {
+ // `wishlist` is a known slug but its feature is off in this test class.
+ $this->assertFalse( ShopperList::get_by_slug( 'wishlist', $this->user_id ) );
+ $this->assertFalse( ShopperList::get_by_slug( 'INVALID', $this->user_id ) );
+ $this->assertFalse( ShopperList::get_by_slug( '', $this->user_id ) );
+ }
+
+ /**
+ * @testdox get_by_slug should return false when the feature is disabled, even when the list has persisted items.
+ */
+ public function test_load_returns_false_when_feature_disabled_for_persisted_list(): void {
+ // Persist a list while the feature is on.
+ $list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+ $list->add_item( $this->item );
+ $list->save();
+ $meta_key = ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG;
+ $this->assertIsArray( Users::get_site_user_meta( $this->user_id, $meta_key ) );
+
+ // Disable the feature; the persisted list must no longer be returned.
+ $this->disable_list( self::SAVED_FOR_LATER_SLUG );
+ $this->assertFalse( ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id ) );
+ }
+
+ /**
+ * @testdox get_by_slug should self-heal saved-for-later when the stored meta is corrupt.
+ */
+ public function test_load_self_heals_corrupt_saved_for_later(): void {
+ Users::update_site_user_meta(
+ $this->user_id,
+ ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG,
+ 'this-is-not-an-array'
+ );
+
+ $list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+ $this->assertInstanceOf( ShopperList::class, $list );
+ $this->assertSame( array(), $list->get_items(), 'Corrupt meta must yield an empty in-memory list.' );
+ }
+
+ /**
+ * @testdox get_by_slug should skip individual corrupt items but still return the rest of the list.
+ */
+ public function test_load_skips_corrupt_items(): void {
+ $good_item = $this->item->to_array();
+ Users::update_site_user_meta(
+ $this->user_id,
+ ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG,
+ array(
+ 'slug' => self::SAVED_FOR_LATER_SLUG,
+ 'date_created_gmt' => '2026-04-01 00:00:00',
+ 'items' => array(
+ $good_item['key'] => $good_item,
+ // Missing key + product_id.
+ 'broken-row-key' => array( 'variation_id' => 0 ),
+ ),
+ )
+ );
+
+ $list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+ $this->assertInstanceOf( ShopperList::class, $list );
+ $this->assertCount( 1, $list->get_items(), 'Bad rows should be skipped, the rest kept.' );
+ $this->assertNotNull( $list->find_item( $good_item['key'] ) );
+ }
+
+ /**
+ * @testdox add_item/remove_item round-trip through save() and reload, merge quantities for the same key, and report unknown keys.
+ */
+ public function test_list_item_crud(): void {
+ $list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+ $list->add_item( $this->item );
+ $list->add_item( $this->item );
+ $list->save();
+
+ $reloaded = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+ $this->assertCount( 1, $reloaded->get_items(), 'Adding the same item twice must keep a single row.' );
+
+ $merged = $reloaded->find_item( $this->item->get_key() );
+ $this->assertNotNull( $merged );
+ $this->assertSame( 2, $merged->get_quantity(), 'Quantities must be summed when the same product+variation is added again.' );
+
+ $this->assertFalse( $reloaded->remove_item( 'nonexistent-key' ), 'remove_item should return false for unknown keys.' );
+ $this->assertTrue( $reloaded->remove_item( $this->item->get_key() ) );
+ $reloaded->save();
+
+ $this->assertSame( array(), ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id )->get_items() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListsControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListsControllerTests.php
new file mode 100644
index 00000000000..c294e37955f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListsControllerTests.php
@@ -0,0 +1,292 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for ShopperListsController.
+ */
+class ShopperListsControllerTests extends WC_Unit_Test_Case {
+
+ private const FLUSH_QUEUE_OPTION = 'woocommerce_queue_flush_rewrite_rules';
+
+ /**
+ * Map of shopper-list slug => feature option key.
+ */
+ private const LIST_OPTIONS = array(
+ 'saved-for-later' => 'woocommerce_cart_save_for_later_enabled',
+ 'wishlist' => 'woocommerce_product_wishlist_enabled',
+ );
+
+ /**
+ * System under test.
+ *
+ * @var ShopperListsController|null
+ */
+ private $sut;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ // Direct construction (not via container) keeps each test isolated:
+ // the controller has no constructor deps, and hooks attached by one
+ // test don't leak into the shared container instance.
+ $this->sut = new ShopperListsController();
+ // Start every test with all features off; individual tests opt in.
+ foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+ $this->disable_list( $slug );
+ }
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ if ( null !== $this->sut ) {
+ $endpoint = $this->sut->get_wishlist_endpoint();
+ remove_filter( 'woocommerce_get_query_vars', array( $this->sut, 'add_wishlist_query_var' ) );
+ remove_filter( 'woocommerce_account_menu_items', array( $this->sut, 'add_wishlist_menu_item' ) );
+ remove_filter( 'woocommerce_endpoint_' . $endpoint . '_title', array( $this->sut, 'wishlist_endpoint_title' ) );
+ remove_action( 'woocommerce_account_' . $endpoint . '_endpoint', array( $this->sut, 'render_wishlist_endpoint' ) );
+ }
+
+ foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+ $this->disable_list( $slug );
+ }
+ delete_option( self::FLUSH_QUEUE_OPTION );
+
+ $this->sut = null;
+ parent::tearDown();
+ }
+
+ /**
+ * Enable the feature backing the given shopper-list slug.
+ *
+ * @param string $slug List slug.
+ */
+ private function enable_list( string $slug ): void {
+ update_option( self::LIST_OPTIONS[ $slug ], 'yes' );
+ }
+
+ /**
+ * Disable the feature backing the given shopper-list slug.
+ *
+ * @param string $slug List slug.
+ */
+ private function disable_list( string $slug ): void {
+ update_option( self::LIST_OPTIONS[ $slug ], 'no' );
+ }
+
+ /**
+ * Convenience for the (sfl, wishlist) combinations used by the providers.
+ *
+ * @param bool $sfl Whether to enable cart_save_for_later.
+ * @param bool $wishlist Whether to enable product_wishlist.
+ */
+ private function set_features( bool $sfl, bool $wishlist ): void {
+ $sfl ? $this->enable_list( 'saved-for-later' ) : $this->disable_list( 'saved-for-later' );
+ $wishlist ? $this->enable_list( 'wishlist' ) : $this->disable_list( 'wishlist' );
+ }
+
+ /**
+ * @testdox is_enabled reflects the underlying feature state, with null asking about any list type.
+ * @dataProvider provide_is_enabled_cases
+ *
+ * @param bool $sfl Whether SFL is enabled in this case.
+ * @param bool $wishlist Whether wishlist is enabled in this case.
+ * @param string|null $slug Slug argument to pass to is_enabled().
+ * @param bool $expected Expected return value.
+ */
+ public function test_is_enabled( bool $sfl, bool $wishlist, ?string $slug, bool $expected ): void {
+ $this->set_features( $sfl, $wishlist );
+ $this->assertSame( $expected, $this->sut->is_enabled( $slug ) );
+ }
+
+ /**
+ * Data provider for {@see test_is_enabled()}.
+ *
+ * @return array<string, array{0:bool, 1:bool, 2:?string, 3:bool}>
+ */
+ public function provide_is_enabled_cases(): array {
+ return array(
+ 'no slug: both off' => array( false, false, null, false ),
+ 'no slug: sfl on' => array( true, false, null, true ),
+ 'no slug: wishlist on' => array( false, true, null, true ),
+ 'no slug: both on' => array( true, true, null, true ),
+ 'saved-for-later: on' => array( true, false, 'saved-for-later', true ),
+ 'saved-for-later: off' => array( false, false, 'saved-for-later', false ),
+ 'wishlist: on' => array( false, true, 'wishlist', true ),
+ 'wishlist: off' => array( false, false, 'wishlist', false ),
+ 'unknown slug never qualifies' => array( true, true, 'unknown-list', false ),
+ );
+ }
+
+ /**
+ * @testdox get_enabled_slugs returns the slugs of currently enabled list types in declaration order.
+ * @dataProvider provide_enabled_slug_cases
+ *
+ * @param bool $sfl Whether SFL is enabled in this case.
+ * @param bool $wishlist Whether wishlist is enabled in this case.
+ * @param array<int, string> $expected Expected slugs.
+ */
+ public function test_get_enabled_slugs( bool $sfl, bool $wishlist, array $expected ): void {
+ $this->set_features( $sfl, $wishlist );
+ $this->assertSame( $expected, $this->sut->get_enabled_slugs() );
+ }
+
+ /**
+ * Data provider for {@see test_get_enabled_slugs()}.
+ *
+ * @return array<string, array{0:bool, 1:bool, 2:array<int, string>}>
+ */
+ public function provide_enabled_slug_cases(): array {
+ return array(
+ 'none' => array( false, false, array() ),
+ 'sfl only' => array( true, false, array( 'saved-for-later' ) ),
+ 'wishlist only' => array( false, true, array( 'wishlist' ) ),
+ 'both' => array( true, true, array( 'saved-for-later', 'wishlist' ) ),
+ );
+ }
+
+ /**
+ * @testdox maybe_flush_rewrite_rules queues a flush only for the product_wishlist feature.
+ * @dataProvider provide_maybe_flush_cases
+ *
+ * @param string $feature_id The feature id passed to the callback.
+ * @param bool $expect_queued Whether the queue option should end up set to 'yes'.
+ */
+ public function test_maybe_flush_rewrite_rules( string $feature_id, bool $expect_queued ): void {
+ delete_option( self::FLUSH_QUEUE_OPTION );
+ $this->sut->maybe_flush_rewrite_rules( $feature_id );
+ $this->assertSame(
+ $expect_queued ? 'yes' : false,
+ get_option( self::FLUSH_QUEUE_OPTION, false )
+ );
+ }
+
+ /**
+ * Data provider for {@see test_maybe_flush_rewrite_rules()}.
+ *
+ * @return array<string, array{0:string, 1:bool}>
+ */
+ public function provide_maybe_flush_cases(): array {
+ return array(
+ 'wishlist toggles flush' => array( 'product_wishlist', true ),
+ 'sfl change ignored' => array( 'cart_save_for_later', false ),
+ 'unrelated feature ignored' => array( 'agentic_checkout', false ),
+ );
+ }
+
+ /**
+ * @testdox add_wishlist_query_var injects the wishlist entry and returns an array for non-array input.
+ * @dataProvider provide_query_var_cases
+ *
+ * @param mixed $input Filter input.
+ * @param array<string, mixed> $expected Expected return value.
+ */
+ public function test_add_wishlist_query_var( $input, array $expected ): void {
+ $this->assertSame( $expected, $this->sut->add_wishlist_query_var( $input ) );
+ }
+
+ /**
+ * Data provider for {@see test_add_wishlist_query_var()}.
+ *
+ * @return array<string, array{0:mixed, 1:array<string, mixed>}>
+ */
+ public function provide_query_var_cases(): array {
+ return array(
+ 'empty' => array( array(), array( 'wishlist' => 'wishlist' ) ),
+ 'with other vars' => array(
+ array( 'orders' => 'orders' ),
+ array(
+ 'orders' => 'orders',
+ 'wishlist' => 'wishlist',
+ ),
+ ),
+ 'non-array input' => array( 'not-an-array', array() ),
+ );
+ }
+
+ /**
+ * @testdox add_wishlist_menu_item inserts the wishlist link before customer-logout when present, or at the end otherwise.
+ * @dataProvider provide_menu_item_cases
+ *
+ * @param mixed $input Filter input.
+ * @param array<int, string> $expected_keys Expected ordered keys.
+ */
+ public function test_add_wishlist_menu_item( $input, array $expected_keys ): void {
+ $result = $this->sut->add_wishlist_menu_item( $input );
+ $this->assertSame( $expected_keys, array_keys( $result ) );
+ }
+
+ /**
+ * Data provider for {@see test_add_wishlist_menu_item()}.
+ *
+ * @return array<string, array{0:mixed, 1:array<int, string>}>
+ */
+ public function provide_menu_item_cases(): array {
+ return array(
+ 'logout present' => array(
+ array(
+ 'dashboard' => 'Dashboard',
+ 'orders' => 'Orders',
+ 'customer-logout' => 'Log out',
+ ),
+ array( 'dashboard', 'orders', 'wishlist', 'customer-logout' ),
+ ),
+ 'logout absent' => array(
+ array(
+ 'dashboard' => 'Dashboard',
+ 'orders' => 'Orders',
+ ),
+ array( 'dashboard', 'orders', 'wishlist' ),
+ ),
+ 'empty' => array( array(), array( 'wishlist' ) ),
+ 'non-array input' => array( 'not-an-array', array() ),
+ );
+ }
+
+ /**
+ * @testdox maybe_register_wishlist_endpoint attaches the endpoint hooks only when wishlist is enabled.
+ * @dataProvider provide_register_endpoint_cases
+ *
+ * @param bool $wishlist_on Whether wishlist is enabled in this case.
+ * @param bool $hooks_expected Whether all four endpoint hooks should be present.
+ */
+ public function test_maybe_register_wishlist_endpoint( bool $wishlist_on, bool $hooks_expected ): void {
+ $this->set_features( false, $wishlist_on );
+ $this->sut->maybe_register_wishlist_endpoint();
+
+ $endpoint = $this->sut->get_wishlist_endpoint();
+ $hooks = array(
+ array( 'woocommerce_get_query_vars', 'add_wishlist_query_var' ),
+ array( 'woocommerce_account_menu_items', 'add_wishlist_menu_item' ),
+ array( 'woocommerce_endpoint_' . $endpoint . '_title', 'wishlist_endpoint_title' ),
+ array( 'woocommerce_account_' . $endpoint . '_endpoint', 'render_wishlist_endpoint' ),
+ );
+ foreach ( $hooks as $hook ) {
+ $this->assertSame(
+ $hooks_expected,
+ (bool) has_filter( $hook[0], array( $this->sut, $hook[1] ) ),
+ "Hook {$hook[0]}::{$hook[1]} state mismatch."
+ );
+ }
+ }
+
+ /**
+ * Data provider for {@see test_maybe_register_wishlist_endpoint()}.
+ *
+ * @return array<string, array{0:bool, 1:bool}>
+ */
+ public function provide_register_endpoint_cases(): array {
+ return array(
+ 'wishlist on attaches hooks' => array( true, true ),
+ 'wishlist off leaves no hooks' => array( false, false ),
+ );
+ }
+}