Commit eeecf7ef29 for woocommerce
commit eeecf7ef299f12f6d65bf4f08a31b337c90d7adc
Author: Alefe Souza <contact@alefesouza.com>
Date: Thu Dec 11 15:44:09 2025 -0300
Show product thumbnails on Blocks Checkout with multiple packages (#61625)
* New design for multiple packages
* Make sure extra packages are selected when switching to Ship mode
* Add unit tests
* Add changefile(s) from automation for the following project(s): woocommerce
* Decrease shipping package font size
* Styling improvements for collapsible shipping methods
* Hide third thumbnail on small screens
* Add extra checks
* Add unit tests for ShippingPackageItemIcon
* Set thumbnail object fit as cover
* Do not use existing address card class
* Fix linting error
* Fix unit tests
* Add better sanitization on package names
* Fix linting error
* Use separate card styles for local pickup
* Rename ProductImageProps to ShippingPackageItemIconProps
* Fix no address notices size
* Update JSDoc
* Update ShippingPackageItemIcon JSDoc description
---------
Co-authored-by: github-actions <github-actions@github.com>
diff --git a/plugins/woocommerce/changelog/61625-update-recurring-carts-product-thumbnails b/plugins/woocommerce/changelog/61625-update-recurring-carts-product-thumbnails
new file mode 100644
index 0000000000..49f6410ee6
--- /dev/null
+++ b/plugins/woocommerce/changelog/61625-update-recurring-carts-product-thumbnails
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Blocks Checkout design improvements for multiple shipping packages.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/css/abstracts/_mixins.scss b/plugins/woocommerce/client/blocks/assets/css/abstracts/_mixins.scss
index bcc2f49414..2a046861c6 100644
--- a/plugins/woocommerce/client/blocks/assets/css/abstracts/_mixins.scss
+++ b/plugins/woocommerce/client/blocks/assets/css/abstracts/_mixins.scss
@@ -392,6 +392,17 @@ $fontSizes: (
}
}
+@mixin card-styles() {
+ @include font-regular-locked;
+ border: 1px solid $universal-border-light;
+ padding: $gap;
+ margin: 0;
+ border-radius: $universal-border-radius;
+ display: flex;
+ justify-content: flex-start;
+ align-items: flex-start;
+}
+
@keyframes wc-skeleton-shimmer {
100% {
transform: translateX(100%);
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/index.js b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/index.js
index 6b03afc288..66f5cf9c6e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/index.js
@@ -17,4 +17,6 @@ export { default as PaymentMethodIcons } from './payment-method-icons';
export { default as PaymentMethodLabel } from './payment-method-label';
export { default as AdditionalFieldsPlaceholder } from './additional-fields-placeholder';
export { default as PasswordStrengthMeter } from './password-strength-meter';
+export { default as PackageItems } from './package-items';
+export { default as ShippingPackageItemIcon } from './shipping-package-item-icon';
export * from './totals';
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx
index 54bfd8a067..745c3e4bc5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx
@@ -7,9 +7,25 @@ import {
} from '@woocommerce/blocks-components';
import { CartShippingPackageShippingRate } from '@woocommerce/types';
import { useShippingData } from '@woocommerce/base-context';
+import clsx from 'clsx';
+import { sanitizeHTML } from '@woocommerce/sanitize';
+import { useStoreCart } from '@woocommerce/base-context/hooks';
+import {
+ PackageItems,
+ ShippingPackageItemIcon,
+} from '@woocommerce/base-components/cart-checkout';
+
+/**
+ * Internal dependencies
+ */
+import type { PackageData } from '../shipping-rates-control-package/types';
+
+import './style.scss';
interface LocalPickupSelectProps {
title?: string | undefined;
+ packageData?: PackageData;
+ showItems?: boolean;
selectedOption: string;
pickupLocations: CartShippingPackageShippingRate[];
renderPickupLocation: (
@@ -24,6 +40,8 @@ interface LocalPickupSelectProps {
*/
export const LocalPickupSelect = ( {
title,
+ packageData = undefined,
+ showItems,
selectedOption,
pickupLocations,
renderPickupLocation,
@@ -31,6 +49,7 @@ export const LocalPickupSelect = ( {
onChange,
}: LocalPickupSelectProps ) => {
const { shippingRates } = useShippingData();
+ const { cartItems } = useStoreCart();
const internalPackageCount = shippingRates?.length || 1;
// Hacky way to check if there are multiple packages, this way is borrowed from `assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx`
// We have no built-in way of checking if other extensions have added packages.
@@ -39,9 +58,60 @@ export const LocalPickupSelect = ( {
document.querySelectorAll(
'.wc-block-components-local-pickup-select .wc-block-components-radio-control'
).length > 1;
+
+ // If showItems is not set, we check if we have multiple packages.
+ // We sometimes don't want to show items even if we have multiple packages.
+ const shouldShowItems = showItems ?? multiplePackages;
+
+ let header = multiplePackages && title && <div>{ title }</div>;
+
+ // packageData was added in version 10.4
+ if ( ( multiplePackages || shouldShowItems ) && packageData ) {
+ header = (
+ <div className="wc-block-components-shipping-rates-control__package-header">
+ <div
+ className="wc-block-components-shipping-rates-control__package-title"
+ dangerouslySetInnerHTML={ {
+ __html: sanitizeHTML(
+ String( packageData.name ?? '' )
+ ),
+ } }
+ />
+ { shouldShowItems && (
+ <PackageItems packageData={ packageData } />
+ ) }
+ </div>
+ );
+
+ if ( multiplePackages ) {
+ const packageItems = packageData.items || [];
+
+ header = (
+ <div className="wc-block-components-shipping-rates-control__package-container">
+ { header }
+ <div className="wc-block-components-shipping-rates-control__package-thumbnails">
+ { packageItems.slice( 0, 3 ).map( ( item ) => (
+ <ShippingPackageItemIcon
+ key={ item.key }
+ packageItem={ item }
+ cartItems={ cartItems }
+ />
+ ) ) }
+ </div>
+ </div>
+ );
+ }
+ }
+
return (
- <div className="wc-block-components-local-pickup-select">
- { multiplePackages && title ? <div>{ title }</div> : false }
+ <div
+ className={ clsx(
+ 'wc-block-components-local-pickup-select',
+ multiplePackages &&
+ 'wc-block-components-local-pickup-select--multiple'
+ ) }
+ >
+ { header }
<RadioControl
onChange={ onChange }
highlightChecked={ true }
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/style.scss
new file mode 100644
index 0000000000..bfdbeb3881
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/style.scss
@@ -0,0 +1,20 @@
+.wc-block-components-local-pickup-rates-control {
+ .wc-block-components-local-pickup-select {
+ margin-bottom: $gap;
+ display: flex;
+ flex-direction: column;
+ gap: $gap-small;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .wc-block-components-radio-control {
+ width: 100%;
+ }
+}
+
+.wc-block-components-local-pickup-select--multiple {
+ @include card-styles();
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx
index e6eaa428f8..d7e9479549 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx
@@ -3,46 +3,82 @@
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { useShippingData, useStoreCart } from '@woocommerce/base-context/hooks';
+import { CartShippingPackageShippingRate } from '@woocommerce/types';
/**
* Internal dependencies
*/
import LocalPickupSelect from '..';
-import { generateShippingRate } from '../../../../../mocks/shipping-package';
+import {
+ generateShippingRate,
+ generateShippingPackage,
+} from '../../../../../mocks/shipping-package';
+
+jest.mock( '@woocommerce/base-context/hooks' );
describe( 'LocalPickupSelect', () => {
+ const defaultPackageData = generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [],
+ } );
+
+ const mockShippingData = ( packageData = defaultPackageData ) => {
+ ( useShippingData as jest.Mock ).mockImplementation( () => ( {
+ shippingRates: [ packageData ],
+ } ) );
+ };
+
+ const defaultRenderPickupLocation = (
+ location: CartShippingPackageShippingRate
+ ) => ( {
+ value: `${ location.rate_id }`,
+ onChange: jest.fn(),
+ label: `${ location.name }`,
+ description: `${ location.description }`,
+ } );
+
+ const defaultPickupLocations = [
+ generateShippingRate( {
+ rateId: '1',
+ name: 'Store 1',
+ instanceID: 1,
+ price: '0',
+ } ),
+ generateShippingRate( {
+ rateId: '2',
+ name: 'Store 2',
+ instanceID: 1,
+ price: '0',
+ } ),
+ ];
+
+ beforeEach( () => {
+ mockShippingData();
+
+ ( useStoreCart as jest.Mock ).mockImplementation( () => ( {
+ cartItems: [],
+ } ) );
+ } );
+
const TestComponent = ( {
onChange,
+ renderPickupLocation = defaultRenderPickupLocation,
+ pickupLocations = defaultPickupLocations,
+ packageCount = 1,
}: {
onChange?: ( value: string ) => void;
+ renderPickupLocation?: typeof defaultRenderPickupLocation;
+ pickupLocations?: typeof defaultPickupLocations;
+ packageCount?: number;
} ) => (
<LocalPickupSelect
title="Package 1"
onChange={ onChange ?? jest.fn() }
selectedOption=""
- pickupLocations={ [
- generateShippingRate( {
- rateId: '1',
- name: 'Store 1',
- instanceID: 1,
- price: '0',
- } ),
- generateShippingRate( {
- rateId: '2',
- name: 'Store 2',
- instanceID: 1,
- price: '0',
- } ),
- ] }
- packageCount={ 1 }
- renderPickupLocation={ ( location ) => {
- return {
- value: `${ location.rate_id }`,
- onChange: jest.fn(),
- label: `${ location.name }`,
- description: `${ location.description }`,
- };
- } }
+ pickupLocations={ pickupLocations }
+ packageCount={ packageCount }
+ renderPickupLocation={ renderPickupLocation }
/>
);
it( 'Does not render the title if only one package is present on the page', () => {
@@ -86,4 +122,262 @@ describe( 'LocalPickupSelect', () => {
await user.click( screen.getByText( 'Store 1' ) );
expect( onChange ).toHaveBeenLastCalledWith( '1' );
} );
+
+ describe( 'packageData prop', () => {
+ it( 'Renders package name when multiple packages are present', () => {
+ const packageDataWithName = {
+ ...generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [],
+ } ),
+ name: 'Test Package Name',
+ };
+
+ mockShippingData( packageDataWithName );
+
+ const { rerender } = render(
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ );
+
+ rerender(
+ <>
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithName }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ [
+ generateShippingRate( {
+ rateId: '1',
+ name: 'Store 1',
+ instanceID: 1,
+ price: '0',
+ } ),
+ ] }
+ packageCount={ 2 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ </>
+ );
+
+ rerender(
+ <>
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithName }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations }
+ packageCount={ 2 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ </>
+ );
+
+ expect(
+ screen.getByText( 'Test Package Name' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'Renders package header with items when showItems is true', () => {
+ const packageDataWithItems = {
+ ...generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [],
+ } ),
+ name: 'Package with Items',
+ };
+
+ mockShippingData( packageDataWithItems );
+
+ render(
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithItems }
+ showItems={ true }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations.slice( 0, 1 ) }
+ packageCount={ 1 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ );
+
+ expect(
+ document.querySelector(
+ '.wc-block-components-shipping-rates-control__package-header'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'Does not render package header when showItems is false and single package', () => {
+ const packageDataWithItems = {
+ ...generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [],
+ } ),
+ name: 'Single Package',
+ };
+
+ mockShippingData( packageDataWithItems );
+
+ render(
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithItems }
+ showItems={ false }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations.slice( 0, 1 ) }
+ packageCount={ 1 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ );
+
+ expect(
+ document.querySelector(
+ '.wc-block-components-shipping-rates-control__package-header'
+ )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'Renders package thumbnails when multiple packages are present', () => {
+ const packageDataWithItems = {
+ ...generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [],
+ } ),
+ name: 'Package with Thumbnails',
+ };
+
+ mockShippingData( packageDataWithItems );
+
+ const { rerender } = render(
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ );
+
+ rerender(
+ <>
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithItems }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations }
+ packageCount={ 2 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ </>
+ );
+
+ rerender(
+ <>
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithItems }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations }
+ packageCount={ 2 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ </>
+ );
+
+ expect(
+ document.querySelector(
+ '.wc-block-components-shipping-rates-control__package-thumbnails'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'Limits package thumbnails to first 3 items', () => {
+ const packageDataWithManyItems = {
+ ...generateShippingPackage( {
+ packageId: 0,
+ shippingRates: [],
+ } ),
+ name: 'Package with Many Items',
+ };
+
+ // Add extra items to test the slice functionality
+ const extraItems = Array.from( { length: 5 }, ( _, i ) => ( {
+ key: `extra-${ i }`,
+ name: `Extra Item ${ i }`,
+ quantity: 1,
+ } ) );
+ packageDataWithManyItems.items = [
+ ...packageDataWithManyItems.items,
+ ...extraItems,
+ ];
+
+ mockShippingData( packageDataWithManyItems );
+
+ const { rerender } = render(
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ );
+
+ rerender(
+ <>
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithManyItems }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations }
+ packageCount={ 2 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ </>
+ );
+
+ rerender(
+ <>
+ <div className="wc-block-components-local-pickup-select">
+ <div className="wc-block-components-radio-control"></div>
+ </div>
+ <LocalPickupSelect
+ title="Package 1"
+ packageData={ packageDataWithManyItems }
+ onChange={ jest.fn() }
+ selectedOption=""
+ pickupLocations={ defaultPickupLocations }
+ packageCount={ 2 }
+ renderPickupLocation={ defaultRenderPickupLocation }
+ />
+ </>
+ );
+
+ const thumbnailsContainer = document.querySelector(
+ '.wc-block-components-shipping-rates-control__package-thumbnails'
+ );
+ // Should only render 3 thumbnails even though there are more items
+ expect(
+ thumbnailsContainer?.querySelectorAll(
+ '.wc-block-components-shipping-package-item-icon'
+ ).length
+ ).toBeLessThanOrEqual( 3 );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-items.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/package-items/index.tsx
similarity index 93%
rename from plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-items.tsx
rename to plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/package-items/index.tsx
index 5de961e41f..8592c72ae9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-items.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/package-items/index.tsx
@@ -8,7 +8,10 @@ import { Label } from '@woocommerce/blocks-components';
/**
* Internal dependencies
*/
-import type { PackageData, PackageItem } from './types';
+import type {
+ PackageData,
+ PackageItem,
+} from '../shipping-rates-control-package/types';
export const PackageItems = ( {
packageData,
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-package-item-icon/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-package-item-icon/index.tsx
new file mode 100644
index 0000000000..2f0efc38cb
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-package-item-icon/index.tsx
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import type { CartItem } from '@woocommerce/types';
+
+/**
+ * Internal dependencies
+ */
+import type { PackageItem } from '../shipping-rates-control-package/types';
+import ProductImage from '../product-image';
+
+interface ShippingPackageItemIconProps {
+ packageItem: PackageItem;
+ cartItems: CartItem[];
+}
+/**
+ * Renders a product image for a package item.
+ *
+ * @param {Object} props Incoming props for the component.
+ * @param {Object} props.packageItem The package item.
+ * @param {Object} props.cartItems The cartItems to get the image from via the packageItem key.
+ * @return {JSX.Element} React node.
+ */
+
+const ShippingPackageItemIcon = ( {
+ packageItem,
+ cartItems = [],
+}: ShippingPackageItemIconProps ): JSX.Element => {
+ const cartItem = cartItems?.find(
+ ( item ) => item.key === packageItem.key
+ );
+ const images = cartItem?.images || [];
+
+ return (
+ <ProductImage
+ image={ images.length ? images[ 0 ] : {} }
+ fallbackAlt={ cartItem?.name || '' }
+ />
+ );
+};
+
+export default ShippingPackageItemIcon;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-package-item-icon/test/index.test.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-package-item-icon/test/index.test.tsx
new file mode 100644
index 0000000000..22c421fb4b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-package-item-icon/test/index.test.tsx
@@ -0,0 +1,230 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import type { CartItem } from '@woocommerce/types';
+
+/**
+ * Internal dependencies
+ */
+import ShippingPackageItemIcon from '../index';
+import type { PackageItem } from '../../shipping-rates-control-package/types';
+
+// Mock the ProductImage component
+jest.mock( '../../product-image', () => {
+ return function ProductImage( {
+ image,
+ fallbackAlt,
+ width,
+ height,
+ }: {
+ image: { alt?: string; thumbnail?: string };
+ fallbackAlt: string;
+ width?: number;
+ height?: number;
+ } ) {
+ return (
+ <img
+ data-testid="product-image"
+ src={ image.thumbnail || '' }
+ alt={ image.alt || fallbackAlt }
+ width={ width }
+ height={ height }
+ />
+ );
+ };
+} );
+
+const mockPackageItem: PackageItem = {
+ key: 'test-item-key',
+ name: 'Test Product',
+ quantity: 1,
+};
+
+const mockCartItemWithImages: CartItem = {
+ key: 'test-item-key',
+ name: 'Test Product',
+ images: [
+ {
+ id: 1,
+ src: 'https://example.com/image1.jpg',
+ thumbnail: 'https://example.com/image1-thumb.jpg',
+ srcset: '',
+ sizes: '',
+ name: 'image1',
+ alt: 'Test Product Image',
+ },
+ {
+ id: 2,
+ src: 'https://example.com/image2.jpg',
+ thumbnail: 'https://example.com/image2-thumb.jpg',
+ srcset: '',
+ sizes: '',
+ name: 'image2',
+ alt: 'Second Image',
+ },
+ ],
+ id: 1,
+ quantity: 1,
+ quantity_limits: {
+ minimum: 1,
+ maximum: 10,
+ multiple_of: 1,
+ editable: true,
+ },
+ catalog_visibility: 'visible',
+ prices: {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_minor_unit: 2,
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ currency_prefix: '$',
+ currency_suffix: '',
+ price: '1000',
+ regular_price: '1000',
+ sale_price: '1000',
+ price_range: null,
+ raw_prices: {
+ precision: 2,
+ price: '1000',
+ regular_price: '1000',
+ sale_price: '1000',
+ },
+ },
+ totals: {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_minor_unit: 2,
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ currency_prefix: '$',
+ currency_suffix: '',
+ line_subtotal: '1000',
+ line_subtotal_tax: '0',
+ line_total: '1000',
+ line_total_tax: '0',
+ },
+ variation: [],
+ item_data: [],
+ low_stock_remaining: null,
+ show_backorder_badge: false,
+ sold_individually: false,
+ permalink: 'https://example.com/product',
+ short_description: '',
+ description: '',
+ sku: '',
+ backorders_allowed: false,
+ type: 'simple',
+ summary: '',
+ extensions: {},
+};
+
+const mockCartItemWithoutImages: CartItem = {
+ ...mockCartItemWithImages,
+ images: [],
+};
+
+describe( 'ShippingPackageItemIcon', () => {
+ it( 'renders ProductImage with the first image and correct props when cart item has images', () => {
+ render(
+ <ShippingPackageItemIcon
+ packageItem={ mockPackageItem }
+ cartItems={ [ mockCartItemWithImages ] }
+ />
+ );
+
+ const image = screen.getByTestId( 'product-image' );
+ expect( image ).toBeInTheDocument();
+ expect( image ).toHaveAttribute(
+ 'src',
+ 'https://example.com/image1-thumb.jpg'
+ );
+ expect( image ).toHaveAttribute( 'alt', 'Test Product Image' );
+ } );
+
+ it.each( [
+ [
+ 'cart item has no images',
+ mockPackageItem,
+ [ mockCartItemWithoutImages ],
+ 'Test Product',
+ ],
+ [
+ 'cart item is not found',
+ { ...mockPackageItem, key: 'non-existent' },
+ [ mockCartItemWithImages ],
+ '',
+ ],
+ [ 'cartItems is empty', mockPackageItem, [], '' ],
+ [
+ 'cartItems is undefined',
+ mockPackageItem,
+ undefined as unknown as CartItem[],
+ '',
+ ],
+ ] )(
+ 'renders placeholder when %s',
+ ( _, packageItem, cartItems, expectedAlt ) => {
+ render(
+ <ShippingPackageItemIcon
+ packageItem={ packageItem }
+ cartItems={ cartItems }
+ />
+ );
+
+ const image = screen.getByTestId( 'product-image' );
+ expect( image ).toBeInTheDocument();
+ expect( image ).toHaveAttribute( 'src', '' );
+ expect( image ).toHaveAttribute( 'alt', expectedAlt );
+ }
+ );
+
+ it( 'uses cart item name as fallback alt text', () => {
+ const cartItemWithImageNoAlt: CartItem = {
+ ...mockCartItemWithImages,
+ images: [
+ {
+ id: 1,
+ src: 'https://example.com/image.jpg',
+ thumbnail: 'https://example.com/image-thumb.jpg',
+ srcset: '',
+ sizes: '',
+ name: 'image',
+ alt: '',
+ },
+ ],
+ };
+
+ render(
+ <ShippingPackageItemIcon
+ packageItem={ mockPackageItem }
+ cartItems={ [ cartItemWithImageNoAlt ] }
+ />
+ );
+
+ const image = screen.getByTestId( 'product-image' );
+ expect( image ).toHaveAttribute( 'alt', 'Test Product' );
+ } );
+
+ it( 'correctly matches cart item by key', () => {
+ const cartItems: CartItem[] = [
+ { ...mockCartItemWithoutImages, key: 'item-1' },
+ { ...mockCartItemWithImages, key: 'test-item-key' },
+ { ...mockCartItemWithoutImages, key: 'item-3' },
+ ];
+
+ render(
+ <ShippingPackageItemIcon
+ packageItem={ mockPackageItem }
+ cartItems={ cartItems }
+ />
+ );
+
+ const image = screen.getByTestId( 'product-image' );
+ expect( image ).toHaveAttribute(
+ 'src',
+ 'https://example.com/image1-thumb.jpg'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx
index 53cd50184e..031b28d05a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx
@@ -5,15 +5,18 @@ import clsx from 'clsx';
import { decodeEntities } from '@wordpress/html-entities';
import { Panel } from '@woocommerce/blocks-components';
import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
-import { useShippingData } from '@woocommerce/base-context/hooks';
+import { useShippingData, useStoreCart } from '@woocommerce/base-context/hooks';
import { sanitizeHTML } from '@woocommerce/sanitize';
import { CartShippingPackageShippingRate } from '@woocommerce/types';
+import {
+ PackageItems,
+ ShippingPackageItemIcon,
+} from '@woocommerce/base-components/cart-checkout';
/**
* Internal dependencies
*/
import PackageRates from './package-rates';
-import PackageItems from './package-items';
import type { PackageProps } from './types';
import './style.scss';
@@ -28,6 +31,7 @@ export const ShippingRatesControlPackage = ( {
highlightChecked = false,
}: PackageProps ) => {
const { selectShippingRate, shippingRates } = useShippingData();
+ const { cartItems } = useStoreCart();
const internalPackageCount = shippingRates?.length || 1;
@@ -67,13 +71,17 @@ export const ShippingRatesControlPackage = ( {
);
// Collapsible and non-collapsible header handling.
- const header =
- shouldBeCollapsible || shouldShowItems ? (
+ let header = null;
+
+ if ( shouldBeCollapsible || shouldShowItems ) {
+ header = (
<div className="wc-block-components-shipping-rates-control__package-header">
<div
className="wc-block-components-shipping-rates-control__package-title"
dangerouslySetInnerHTML={ {
- __html: sanitizeHTML( packageData.name ),
+ __html: sanitizeHTML(
+ String( packageData.name ?? '' )
+ ),
} }
/>
{ shouldBeCollapsible && (
@@ -85,7 +93,27 @@ export const ShippingRatesControlPackage = ( {
<PackageItems packageData={ packageData } />
) }
</div>
- ) : null;
+ );
+
+ if ( multiplePackages ) {
+ const packageItems = packageData.items || [];
+
+ header = (
+ <div className="wc-block-components-shipping-rates-control__package-container">
+ { header }
+ <div className="wc-block-components-shipping-rates-control__package-thumbnails">
+ { packageItems.slice( 0, 3 ).map( ( item ) => (
+ <ShippingPackageItemIcon
+ key={ item.key }
+ packageItem={ item }
+ cartItems={ cartItems }
+ />
+ ) ) }
+ </div>
+ </div>
+ );
+ }
+ }
const onSelectRate = useCallback(
( newShippingRateId: string ) => {
@@ -111,6 +139,8 @@ export const ShippingRatesControlPackage = ( {
<Panel
className={ clsx(
'wc-block-components-shipping-rates-control__package',
+ multiplePackages &&
+ 'wc-block-components-shipping-rates-control__package--multiple',
className
) }
// initialOpen remembers only the first value provided to it, so by the
@@ -129,6 +159,8 @@ export const ShippingRatesControlPackage = ( {
<div
className={ clsx(
'wc-block-components-shipping-rates-control__package',
+ multiplePackages &&
+ 'wc-block-components-shipping-rates-control__package--multiple',
className
) }
>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/style.scss
index 8b6323c379..addcf9567e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/style.scss
@@ -28,22 +28,77 @@
padding-bottom: em($gap-small);
}
- .wc-block-components-radio-control,
- .wc-block-components-radio-control__option-layout {
+ .wc-block-components-radio-control
+ .wc-block-components-radio-control__option-layout {
padding-bottom: 0;
}
- .wc-block-components-radio-control
- .wc-block-components-radio-control__option-layout {
+ .wc-block-components-radio-control__description,
+ .wc-block-components-radio-control__secondary-description {
+ @include font-small-locked;
+ width: 100%;
+ text-align: left;
+ margin: 0;
+ display: block;
+ }
+
+ .wc-block-components-panel__button[aria-expanded="true"] {
padding-bottom: 0;
+ margin-bottom: 0;
+ }
+
+ .wc-block-components-shipping-rates-control__package-header {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+
+ .wc-block-components-shipping-rates-control__package-header:last-child {
+ margin-bottom: 0;
+ }
}
}
-.wc-block-components-shipping-rates-control__package-header {
- margin: 0 0 $gap-small;
+.wc-block-components-shipping-rates-control__package-container {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
- &:last-child {
- margin-bottom: 0;
+ .wc-block-components-shipping-rates-control__package-thumbnails {
+ display: flex;
+ gap: $gap-smallest;
+ align-items: center;
+
+ > img {
+ object-fit: cover;
+ height: 40px;
+ width: 40px;
+ }
+
+ &:first-child {
+ margin-left: $gap-smallest;
+ }
+
+ @media screen and ( max-width: 480px ) {
+ img:nth-child(3) {
+ display: none;
+ }
+ }
+ }
+}
+
+.wc-block-components-shipping-rates-control__package--multiple {
+ @include card-styles();
+
+ .wc-block-components-panel__content,
+ .wc-block-components-radio-control,
+ .wc-block-components-radio-control__option-layout {
+ padding-bottom: 0;
+ width: 100%;
+ }
+
+ .wc-block-components-panel__button {
+ padding-bottom: 0;
+ padding-top: 0;
}
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
index c43ab5f906..bb5f3a3352 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
@@ -3,7 +3,7 @@
*/
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { useShippingData } from '@woocommerce/base-context/hooks';
+import { useShippingData, useStoreCart } from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
@@ -43,6 +43,12 @@ test( 'renders available shipping rates', async () => {
};
} );
+ ( useStoreCart as jest.Mock ).mockImplementation( () => {
+ return {
+ cartItems: [],
+ };
+ } );
+
render(
<ShippingRatesControlPackage
packageData={ testPackageData }
@@ -77,6 +83,12 @@ test( 'changes rate selection locally and informs API about it', async () => {
};
} );
+ ( useStoreCart as jest.Mock ).mockImplementation( () => {
+ return {
+ cartItems: [],
+ };
+ } );
+
render(
<ShippingRatesControlPackage
packageData={ testPackageData }
@@ -134,6 +146,12 @@ test( 'upstream rate selection updates are properly reflected in local state', a
};
} );
+ ( useStoreCart as jest.Mock ).mockImplementation( () => {
+ return {
+ cartItems: [],
+ };
+ } );
+
const { rerender } = render(
<ShippingRatesControlPackage
packageData={ packageData }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-card/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-card/style.scss
index d7f2f080b2..88946da68e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-card/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-card/style.scss
@@ -1,12 +1,5 @@
.wc-block-components-address-card {
- @include font-regular-locked;
- border: 1px solid $universal-border-light;
- padding: $gap;
- margin: 0;
- border-radius: $universal-border-radius;
- display: flex;
- justify-content: flex-start;
- align-items: flex-start;
+ @include card-styles();
.has-dark-controls & {
border-color: $input-border-dark;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx
index e0a66d28e3..5c9e217aa3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx
@@ -209,12 +209,15 @@ const Block = () => {
renderPickupLocation,
};
+ const packageData = shippingRates[ 0 ] || null;
+
return (
<>
<ExperimentalOrderLocalPickupPackages.Slot { ...slotFillProps } />
<ExperimentalOrderLocalPickupPackages>
<LocalPickupSelect
- title={ shippingRates[ 0 ].name }
+ title={ packageData?.name }
+ packageData={ packageData }
selectedOption={ selectedOption ?? '' }
renderPickupLocation={ renderPickupLocation }
pickupLocations={ pickupLocations }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/style.scss
index 0a20ee882b..4dda989fbb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/style.scss
@@ -60,6 +60,7 @@
.wc-block-components-radio-control__description,
.wc-block-components-radio-control__secondary-description {
+ @include font-small-locked;
width: 100%;
text-align: left;
margin: 0;
@@ -71,8 +72,6 @@
align-items: center;
gap: $gap-smallest;
- color: $gray-700;
-
.has-dark-controls & {
color: $gray-300;
}
@@ -81,7 +80,6 @@
fill: currentColor;
// Pixel perfect alignement needed as internal padding of the SVG
// is inconsistent (top one bigger that the bottom one).
- margin-top: -2px;
margin-left: -3px;
}
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/style.scss
index 24cf3cbf1d..9a2b15a5fd 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/style.scss
@@ -5,6 +5,8 @@
.wc-block-components-shipping-rates-control__no-results-notice {
margin: 0;
+ width: 100%;
+ box-sizing: border-box;
}
.wc-block-components-shipping-rates-control__no-shipping-address-message {
@@ -14,6 +16,8 @@
color: color-mix(in srgb, currentColor 66%, transparent);
text-align: center;
margin: 0;
+ width: 100%;
+ box-sizing: border-box;
border-radius: $universal-border-radius;
}
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/styles/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/styles/style.scss
index f73d2645fd..c0a4faec47 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/styles/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/styles/style.scss
@@ -10,8 +10,10 @@
top: -96px;
}
.wc-block-components-shipping-rates-control__package {
- border-bottom: 0;
- margin: 0 0 1em;
+ margin: 0 0 $gap;
+ display: flex;
+ flex-direction: column;
+ gap: $gap-small;
&:last-child {
margin-bottom: 0;
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 1bb608863b..1a13b2727d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
@@ -127,7 +127,6 @@ export const apiFetchWithHeadersControl = ( options: APIFetchOptions ) =>
// List of paths which should not be batched.
const preventBatching = [
- '/wc/store/v1/cart/select-shipping-rate',
'/wc/store/v1/checkout',
'/wc/store/v1/checkout?__experimental_calc_totals=true',
];