rebase on oct-10-2023

This commit is contained in:
Rachit Bhargava
2023-10-10 17:23:21 -04:00
parent d37566ffb6
commit d096058d7d
4789 changed files with 254611 additions and 307223 deletions

View File

@@ -92,7 +92,6 @@ $fontSizes: (
word-wrap: normal !important;
padding: 0;
position: absolute !important;
width: 1px;
}
@mixin visually-hidden-focus-reveal() {
@@ -123,8 +122,11 @@ $fontSizes: (
vertical-align: baseline;
}
@mixin reset-typography() {
@mixin reset-color() {
color: inherit;
}
@mixin reset-typography() {
font-family: inherit;
font-size: inherit;
font-style: inherit;
@@ -138,6 +140,7 @@ $fontSizes: (
// Reset <h1>, <h2>, etc. styles as if they were text. Useful for elements that must be headings for a11y but don't need those styles.
@mixin text-heading() {
@include reset-box();
@include reset-color();
@include reset-typography();
box-shadow: none;
display: inline;
@@ -148,6 +151,7 @@ $fontSizes: (
// Reset <button> style as if it was text. Useful for elements that must be `<button>` for a11y but don't need those styles.
@mixin text-button() {
@include reset-box();
@include reset-color();
@include reset-typography();
background: transparent;
box-shadow: none;

View File

@@ -8,6 +8,10 @@
.wc-block-grid__product {
margin: 0 0 $gap-large 0;
.wc-block-grid__product-onsale {
position: absolute;
}
}
}

View File

@@ -139,7 +139,8 @@
}
}
}
.wc-block-grid__product-onsale {
.wc-block-grid__product-image .wc-block-grid__product-onsale,
.wc-block-grid .wc-block-grid__product-onsale {
@include font-size(small);
padding: em($gap-smallest) em($gap-small);
display: inline-block;
@@ -152,7 +153,10 @@
text-transform: uppercase;
font-weight: 600;
z-index: 9;
position: relative;
position: absolute;
top: 4px;
right: 4px;
left: auto;
}
// Element spacing.
@@ -336,3 +340,7 @@
.screen-reader-text:focus {
@include visually-hidden-focus-reveal();
}
.wp-block-group.woocommerce.product .up-sells.upsells.products {
max-width: var(--wp--style--global--wide-size);
}

View File

@@ -45,6 +45,33 @@ registerBlockComponent( {
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating-stars',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating-stars" */ './product-elements/rating-stars/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating-counter',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating-counter" */ './product-elements/rating-counter/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-average-rating',
component: lazy( () =>
import(
/* webpackChunkName: "product-average-rating" */ './product-elements/average-rating/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-button',
component: lazy( () =>

View File

@@ -5,6 +5,9 @@ import './product-elements/title';
import './product-elements/price';
import './product-elements/image';
import './product-elements/rating';
import './product-elements/rating-stars';
import './product-elements/rating-counter';
import './product-elements/average-rating';
import './product-elements/button';
import './product-elements/summary';
import './product-elements/sale-badge';

View File

@@ -1,8 +1,14 @@
{
"name": "woocommerce/add-to-cart-form",
"version": "1.0.0",
"title": "Add to Cart form",
"title": "Add to Cart with Options",
"description": "Display a button so the customer can add a product to their cart. Options will also be displayed depending on product type. e.g. quantity, variation.",
"attributes": {
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
}
},
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"usesContext": ["postId"],

View File

@@ -1,23 +1,38 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { Button, Disabled, Tooltip } from '@wordpress/components';
import { Skeleton } from '@woocommerce/base-components/skeleton';
import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
export interface Attributes {
className?: string;
isDescendentOfSingleProductBlock: boolean;
}
const Edit = () => {
const Edit = ( props: BlockEditProps< Attributes > ) => {
const { setAttributes } = props;
const blockProps = useBlockProps( {
className: 'wc-block-add-to-cart-form',
} );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
useEffect( () => {
setAttributes( {
isDescendentOfSingleProductBlock,
} );
}, [ setAttributes, isDescendentOfSingleProductBlock ] );
return (
<div { ...blockProps }>
@@ -34,6 +49,7 @@ const Edit = () => {
className={
'wc-block-editor-add-to-cart-form__quantity'
}
readOnly
/>
<Button
variant={ 'primary' }

View File

@@ -9,6 +9,8 @@ import { Icon, button } from '@wordpress/icons';
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
import './editor.scss';
const blockSettings = {
edit,
@@ -30,4 +32,5 @@ registerBlockSingleProductTemplate( {
blockName: metadata.name,
blockMetadata: metadata,
blockSettings,
isAvailableOnPostEditor: true,
} );

View File

@@ -1,7 +1,12 @@
.wp-block-add-to-cart-form {
.woocommerce-Price-amount.amount,
.woocommerce-grouped-product-list-item__price del {
font-size: var(--wp--preset--font-size--large);
}
width: unset;
/**
* This is a base style for the input text element in WooCommerce that prevents inputs from appearing too small.
*
* @link https://github.com/woocommerce/woocommerce/blob/95ca53675f2817753d484583c96ca9ab9f725172/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss#L203-L206
*/
.input-text {
font-size: var(--wp--preset--font-size--small);
padding: 0.9rem 1.1rem;
}
}

View File

@@ -1,14 +1,13 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import {
AddToCartFormContextProvider,
useAddToCartFormContext,
} from '@woocommerce/base-context';
import { useProductDataContext } from '@woocommerce/shared-context';
import { isEmpty } from 'lodash';
import { isEmpty } from '@woocommerce/types';
import { withProductDataContext } from '@woocommerce/shared-hocs';
/**
@@ -85,8 +84,4 @@ const Block = ( { className, showFormElements }: Props ) => {
);
};
Block.propTypes = {
className: PropTypes.string,
};
export default withProductDataContext( Block );

View File

@@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { keyBy } from 'lodash';
import { decodeEntities } from '@wordpress/html-entities';
import {
Dictionary,
@@ -10,6 +9,7 @@ import {
ProductResponseTermItem,
ProductResponseVariationsItem,
} from '@woocommerce/types';
import { keyBy } from '@woocommerce/base-utils';
/**
* Internal dependencies

View File

@@ -0,0 +1,17 @@
{
"name": "woocommerce/product-average-rating",
"version": "1.0.0",
"title": "Product Average Rating (Beta)",
"description": "Display the average rating of a product",
"attributes": {
"textAlign": {
"type": "string"
}
},
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { useProductDataContext } from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { __ } from '@wordpress/i18n';
import { withProductDataContext } from '@woocommerce/shared-hocs';
type ProductAverageRatingProps = {
className?: string;
textAlign?: string;
};
export const Block = ( props: ProductAverageRatingProps ): JSX.Element => {
const { textAlign } = props;
const styleProps = useStyleProps( props );
const { product } = useProductDataContext();
const className = classnames(
styleProps.className,
'wc-block-components-product-average-rating',
{
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
return (
<div className={ className } style={ styleProps.style }>
{ Number( product.average_rating ) > 0
? product.average_rating
: __( 'No ratings', 'woo-gutenberg-products-block' ) }
</div>
);
};
export default withProductDataContext( Block );

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
export interface BlockAttributes {
textAlign: string;
}
const Edit = ( props: BlockEditProps< BlockAttributes > ): JSX.Element => {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-average-rating',
} );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...attributes } />
</div>
</>
);
};
export default Edit;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starHalf } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starHalf }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );

View File

@@ -0,0 +1,26 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: true,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
__experimentalSkipSerialization: true,
},
typography: {
fontSize: true,
__experimentalFontWeight: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-average-rating',
} ),
};

View File

@@ -1,24 +0,0 @@
/**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
textAlign: {
type: 'string',
default: '',
},
width: {
type: 'number',
},
};
export default blockAttributes;

View File

@@ -0,0 +1,66 @@
{
"name": "woocommerce/product-button",
"version": "1.0.0",
"title": "Add to Cart Button",
"description": "Display a call to action button which either adds the product to the cart, or links to the product page.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"usesContext": [ "query", "queryId", "postId" ],
"textdomain": "woocommerce",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"textAlign": {
"type": "string",
"default": ""
},
"width": {
"type": "number"
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
}
},
"supports": {
"align": [ "wide", "full" ],
"color": {
"background": false,
"link": true
},
"interactivity": true,
"html": false,
"typography": {
"fontSize": true,
"lineHeight": true
}
},
"ancestor": [
"woocommerce/all-products",
"woocommerce/single-product",
"core/post-template",
"woocommerce/product-template"
],
"styles": [
{
"name": "fill",
"label": "Fill",
"isDefault": true
},
{
"name": "outline",
"label": "Outline"
}
],
"viewScript": [
"wc-product-button-interactivity-frontend"
],
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -7,12 +7,7 @@ import {
useStoreEvents,
useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import {
useBorderProps,
useColorProps,
useTypographyProps,
useSpacingProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { CART_URL } from '@woocommerce/block-settings';
import { getSetting } from '@woocommerce/settings';
@@ -35,22 +30,18 @@ import type {
/**
* Product Button Block Component.
*
* @param {Object} props Incoming props.
* @param {Object} [props.product] Product.
* @param {Object} [props.colorStyles] Object contains CSS class and CSS style for color.
* @param {Object} [props.borderStyles] Object contains CSS class and CSS style for border.
* @param {Object} [props.typographyStyles] Object contains CSS class and CSS style for typography.
* @param {Object} [props.spacingStyles] Object contains CSS style for spacing.
* @param {Object} [props.textAlign] Text alignment.
* @param {Object} props Incoming props.
* @param {Object} [props.product] Product.
* @param {Object} [props.style] Object contains CSS Styles.
* @param {string} [props.className] String contains CSS class.
* @param {Object} [props.textAlign] Text alignment.
*
* @return {*} The component.
*/
const AddToCartButton = ( {
product,
colorStyles,
borderStyles,
typographyStyles,
spacingStyles,
className,
style,
textAlign,
}: AddToCartButtonAttributes ): JSX.Element => {
const {
@@ -114,14 +105,15 @@ const AddToCartButton = ( {
return (
<ButtonTag
{ ...buttonProps }
aria-label={ buttonAriaLabel }
disabled={ addingToCart }
className={ classnames(
className,
'wp-block-button__link',
'wp-element-button',
'add_to_cart_button',
'wc-block-components-product-button__button',
colorStyles.className,
borderStyles.className,
{
loading: addingToCart,
added: addedToCart,
@@ -130,14 +122,7 @@ const AddToCartButton = ( {
[ `has-text-align-${ textAlign }` ]: textAlign,
}
) }
style={ {
...colorStyles.style,
...borderStyles.style,
...typographyStyles.style,
...spacingStyles.style,
} }
disabled={ addingToCart }
{ ...buttonProps }
style={ style }
>
{ buttonText }
</ButtonTag>
@@ -147,19 +132,15 @@ const AddToCartButton = ( {
/**
* Product Button Block Component.
*
* @param {Object} props Incoming props.
* @param {Object} [props.colorStyles] Object contains CSS class and CSS style for color.
* @param {Object} [props.borderStyles] Object contains CSS class and CSS style for border.
* @param {Object} [props.typographyStyles] Object contains CSS class and CSS style for typography.
* @param {Object} [props.spacingStyles] Object contains CSS style for spacing.
* @param {Object} props Incoming props.
* @param {Object} [props.style] Object contains CSS Styles.
* @param {string} [props.className] String contains CSS class.
*
* @return {*} The component.
*/
const AddToCartButtonPlaceholder = ( {
colorStyles,
borderStyles,
typographyStyles,
spacingStyles,
className,
style,
}: AddToCartButtonPlaceholderAttributes ): JSX.Element => {
return (
<button
@@ -169,15 +150,9 @@ const AddToCartButtonPlaceholder = ( {
'add_to_cart_button',
'wc-block-components-product-button__button',
'wc-block-components-product-button__button--placeholder',
colorStyles.className,
borderStyles.className
className
) }
style={ {
...colorStyles.style,
...borderStyles.style,
...typographyStyles.style,
...spacingStyles.style,
} }
style={ style }
disabled={ true }
/>
);
@@ -193,12 +168,9 @@ const AddToCartButtonPlaceholder = ( {
*/
export const Block = ( props: BlockAttributes ): JSX.Element => {
const { className, textAlign } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const colorProps = useColorProps( props );
const borderProps = useBorderProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
return (
<div
@@ -218,17 +190,13 @@ export const Block = ( props: BlockAttributes ): JSX.Element => {
{ product.id ? (
<AddToCartButton
product={ product }
colorStyles={ colorProps }
borderStyles={ borderProps }
typographyStyles={ typographyProps }
spacingStyles={ spacingProps }
style={ styleProps.style }
className={ styleProps.className }
/>
) : (
<AddToCartButtonPlaceholder
colorStyles={ colorProps }
borderStyles={ borderProps }
typographyStyles={ typographyProps }
spacingStyles={ spacingProps }
style={ styleProps.style }
className={ styleProps.className }
/>
) }
</div>

View File

@@ -1,19 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, button } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Add to Cart Button',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ button } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display a call to action button which either adds the product to the cart, or links to the product page.',
'woo-gutenberg-products-block'
);
export const BLOCK_NAME = 'woocommerce/product-button';

View File

@@ -0,0 +1,279 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* External dependencies
*/
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { store as interactivityStore } from '@woocommerce/interactivity';
import { dispatch, select, subscribe } from '@wordpress/data';
import { Cart } from '@woocommerce/type-defs/cart';
import { createRoot } from '@wordpress/element';
import NoticeBanner from '@woocommerce/base-components/notice-banner';
type Context = {
woocommerce: {
isLoading: boolean;
addToCartText: string;
productId: number;
displayViewCart: boolean;
quantityToAdd: number;
temporaryNumberOfItems: number;
animationStatus: AnimationStatus;
};
};
enum AnimationStatus {
IDLE = 'IDLE',
SLIDE_OUT = 'SLIDE-OUT',
SLIDE_IN = 'SLIDE-IN',
}
type State = {
woocommerce: {
cart: Cart | undefined;
inTheCartText: string;
};
};
type Store = {
state: State;
context: Context;
selectors: any;
ref: HTMLElement;
};
const storeNoticeClass = '.wc-block-store-notices';
const createNoticeContainer = () => {
const noticeContainer = document.createElement( 'div' );
noticeContainer.classList.add( storeNoticeClass.replace( '.', '' ) );
return noticeContainer;
};
const injectNotice = ( domNode: Element, errorMessage: string ) => {
const root = createRoot( domNode );
root.render(
<NoticeBanner status="error" onRemove={ () => root.unmount() }>
{ errorMessage }
</NoticeBanner>
);
domNode?.scrollIntoView( {
behavior: 'smooth',
inline: 'nearest',
} );
};
const getProductById = ( cartState: Cart | undefined, productId: number ) => {
return cartState?.items.find( ( item ) => item.id === productId );
};
const getTextButton = ( {
addToCartText,
inTheCartText,
numberOfItems,
}: {
addToCartText: string;
inTheCartText: string;
numberOfItems: number;
} ) => {
if ( numberOfItems === 0 ) {
return addToCartText;
}
return inTheCartText.replace( '###', numberOfItems.toString() );
};
const productButtonSelectors = {
woocommerce: {
addToCartText: ( store: Store ) => {
const { context, state, selectors } = store;
// We use the temporary number of items when there's no animation, or the
// second part of the animation hasn't started.
if (
context.woocommerce.animationStatus === AnimationStatus.IDLE ||
context.woocommerce.animationStatus ===
AnimationStatus.SLIDE_OUT
) {
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems: context.woocommerce.temporaryNumberOfItems,
} );
}
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems:
selectors.woocommerce.numberOfItemsInTheCart( store ),
} );
},
displayViewCart: ( store: Store ) => {
const { context, selectors } = store;
if ( ! context.woocommerce.displayViewCart ) return false;
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
return context.woocommerce.temporaryNumberOfItems > 0;
}
return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0;
},
hasCartLoaded: ( { state }: { state: State } ) => {
return state.woocommerce.cart !== undefined;
},
numberOfItemsInTheCart: ( { state, context }: Store ) => {
const product = getProductById(
state.woocommerce.cart,
context.woocommerce.productId
);
return product?.quantity || 0;
},
slideOutAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT,
slideInAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN,
},
};
interactivityStore(
// @ts-expect-error: Store function isn't typed.
{
selectors: productButtonSelectors,
actions: {
woocommerce: {
addToCart: async ( store: Store ) => {
const { context, selectors, ref } = store;
if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) {
return;
}
context.woocommerce.isLoading = true;
// Allow 3rd parties to validate and quit early.
// https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77
const event = new CustomEvent(
'should_send_ajax_request.adding_to_cart',
{ detail: [ ref ], cancelable: true }
);
const shouldSendRequest =
document.body.dispatchEvent( event );
if ( shouldSendRequest === false ) {
const ajaxNotSentEvent = new CustomEvent(
'ajax_request_not_sent.adding_to_cart',
{ detail: [ false, false, ref ] }
);
document.body.dispatchEvent( ajaxNotSentEvent );
return true;
}
try {
await dispatch( storeKey ).addItemToCart(
context.woocommerce.productId,
context.woocommerce.quantityToAdd
);
// After the cart has been updated, sync the temporary number of
// items again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
} catch ( error ) {
const storeNoticeBlock =
document.querySelector( storeNoticeClass );
if ( ! storeNoticeBlock ) {
document
.querySelector( '.entry-content' )
?.prepend( createNoticeContainer() );
}
const domNode =
storeNoticeBlock ??
document.querySelector( storeNoticeClass );
if ( domNode ) {
injectNotice( domNode, error.message );
}
// We don't care about errors blocking execution, but will
// console.error for troubleshooting.
// eslint-disable-next-line no-console
console.error( error );
} finally {
context.woocommerce.displayViewCart = true;
context.woocommerce.isLoading = false;
}
},
handleAnimationEnd: (
store: Store & { event: AnimationEvent }
) => {
const { event, context, selectors } = store;
if ( event.animationName === 'slideOut' ) {
// When the first part of the animation (slide-out) ends, we move
// to the second part (slide-in).
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_IN;
} else if ( event.animationName === 'slideIn' ) {
// When the second part of the animation ends, we update the
// temporary number of items to sync it with the cart and reset the
// animation status so it can be triggered again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
context.woocommerce.animationStatus =
AnimationStatus.IDLE;
}
},
},
},
effects: {
woocommerce: {
startAnimation: ( store: Store ) => {
const { context, selectors } = store;
// We start the animation if the cart has loaded, the temporary number
// of items is out of sync with the number of items in the cart, the
// button is not loading (because that means the user started the
// interaction) and the animation hasn't started yet.
if (
selectors.woocommerce.hasCartLoaded( store ) &&
context.woocommerce.temporaryNumberOfItems !==
selectors.woocommerce.numberOfItemsInTheCart(
store
) &&
! context.woocommerce.isLoading &&
context.woocommerce.animationStatus ===
AnimationStatus.IDLE
) {
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_OUT;
}
},
},
},
},
{
afterLoad: ( store: Store ) => {
const { state, selectors } = store;
// Subscribe to changes in Cart data.
subscribe( () => {
const cartData = select( storeKey ).getCartData();
const isResolutionFinished =
select( storeKey ).hasFinishedResolution( 'getCartData' );
if ( isResolutionFinished ) {
state.woocommerce.cart = cartData;
}
}, storeKey );
// This selector triggers a fetch of the Cart data. It is done in a
// `requestIdleCallback` to avoid potential performance issues.
requestIdleCallback( () => {
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
select( storeKey ).getCartData();
}
} );
},
}
);

View File

@@ -1,52 +0,0 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { supports } from './supports';
import attributes from './attributes';
import sharedConfig from '../shared/config';
import edit from './edit';
import save from './save';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
BLOCK_NAME,
} from './constants';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
],
usesContext: [ 'query', 'queryId', 'postId' ],
icon: { src: icon },
attributes,
supports,
edit,
save,
styles: [
{
name: 'fill',
label: __( 'Fill', 'woo-gutenberg-products-block' ),
isDefault: true,
},
{
name: 'outline',
label: __( 'Outline', 'woo-gutenberg-products-block' ),
},
],
};
registerBlockType( BLOCK_NAME, { ...blockConfig } );

View File

@@ -1,11 +1,19 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { Icon, button } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
export const supports = {
const featurePluginSupport = {
...metadata.supports,
...( isFeaturePluginBuild() && {
color: {
text: true,
@@ -47,3 +55,22 @@ export const supports = {
},
} ),
};
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ button }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
},
supports: {
...featurePluginSupport,
},
edit,
save,
} );

View File

@@ -14,7 +14,10 @@ type Props = {
};
const Save = ( { attributes }: Props ): JSX.Element | null => {
if ( attributes.isDescendentOfQueryLoop ) {
if (
attributes.isDescendentOfQueryLoop ||
attributes.isDescendentOfSingleProductBlock
) {
return null;
}

View File

@@ -1,17 +1,79 @@
.wp-block-button.wc-block-components-product-button {
word-break: break-word;
white-space: normal;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
gap: $gap-small;
.wp-block-button__link {
word-break: break-word;
white-space: normal;
display: inline-flex;
justify-content: center;
text-align: center;
// Set button font size and padding so it inherits from parent.
padding: 0.5em 1em;
font-size: 1em;
&.loading {
opacity: 0.25;
}
&.loading::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e031";
animation: spin 2s linear infinite;
margin-left: 0.5em;
display: inline-block;
width: auto;
height: auto;
}
}
a[hidden] {
display: none;
}
@keyframes slideOut {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
@keyframes slideIn {
from {
transform: translateY(90%);
opacity: 0;
}
to {
transform: translate(0);
opacity: 1;
}
}
.wc-block-components-product-button__button {
border-style: none;
display: inline-flex;
font-family: inherit;
font-weight: inherit;
justify-content: center;
margin-right: auto;
margin-left: auto;
white-space: normal;
word-break: break-word;
width: 150px;
overflow: hidden;
span {
&.wc-block-slide-out {
animation: slideOut 0.1s linear 1 normal forwards;
}
&.wc-block-slide-in {
animation: slideIn 0.1s linear 1 normal;
}
}
}
.wc-block-components-product-button__button--placeholder {

View File

@@ -10,14 +10,13 @@ export interface BlockAttributes {
className?: string | undefined;
textAlign?: string | undefined;
isDescendentOfQueryLoop?: boolean | undefined;
isDescendentOfSingleProductBlock?: boolean | undefined;
width?: number | undefined;
}
export interface AddToCartButtonPlaceholderAttributes {
borderStyles: WithClass & WithStyle;
colorStyles: WithClass & WithStyle;
spacingStyles: WithStyle;
typographyStyles: WithStyle;
className: string;
style: React.CSSProperties;
}
export interface AddToCartButtonAttributes

View File

@@ -3,6 +3,11 @@
*/
import type { BlockAttributes } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { ImageSizing } from './types';
export const blockAttributes: BlockAttributes = {
showProductLink: {
type: 'boolean',
@@ -18,7 +23,7 @@ export const blockAttributes: BlockAttributes = {
},
imageSizing: {
type: 'string',
default: 'full-size',
default: ImageSizing.SINGLE,
},
productId: {
type: 'number',
@@ -32,6 +37,16 @@ export const blockAttributes: BlockAttributes = {
type: 'boolean',
default: false,
},
width: {
type: 'string',
},
height: {
type: 'string',
},
scale: {
type: 'string',
default: 'cover',
},
};
export default blockAttributes;

View File

@@ -9,11 +9,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useBorderProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import type { HTMLAttributes } from 'react';
@@ -23,11 +19,12 @@ import type { HTMLAttributes } from 'react';
*/
import ProductSaleBadge from '../sale-badge/block';
import './style.scss';
import type { BlockAttributes } from './types';
import { BlockAttributes, ImageSizing } from './types';
const ImagePlaceholder = (): JSX.Element => {
const ImagePlaceholder = ( props ): JSX.Element => {
return (
<img
{ ...props }
src={ PLACEHOLDER_IMG_SRC }
alt=""
width={ undefined }
@@ -49,6 +46,9 @@ interface ImageProps {
loaded: boolean;
showFullSize: boolean;
fallbackAlt: string;
scale: string;
width?: string | undefined;
height?: string | undefined;
}
const Image = ( {
@@ -56,6 +56,9 @@ const Image = ( {
loaded,
showFullSize,
fallbackAlt,
width,
scale,
height,
}: ImageProps ): JSX.Element => {
const { thumbnail, src, srcset, sizes, alt } = image || {};
const imageProps = {
@@ -65,13 +68,23 @@ const Image = ( {
...( showFullSize && { src, srcSet: srcset, sizes } ),
};
const imageStyles: Record< string, string | undefined > = {
height,
width,
objectFit: scale,
};
return (
<>
{ imageProps.src && (
/* eslint-disable-next-line jsx-a11y/alt-text */
<img data-testid="product-image" { ...imageProps } />
<img
style={ imageStyles }
data-testid="product-image"
{ ...imageProps }
/>
) }
{ ! image && <ImagePlaceholder /> }
{ ! image && <ImagePlaceholder style={ imageStyles } /> }
</>
);
};
@@ -81,17 +94,19 @@ type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const {
className,
imageSizing = 'full-size',
imageSizing = ImageSizing.SINGLE,
showProductLink = true,
showSaleBadge,
saleBadgeAlign = 'right',
height,
width,
scale,
...restProps
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product, isLoading } = useProductDataContext();
const { dispatchStoreEvent } = useStoreEvents();
const typographyProps = useTypographyProps( props );
const borderProps = useBorderProps( props );
const spacingProps = useSpacingProps( props );
if ( ! product.id ) {
return (
@@ -103,13 +118,8 @@ export const Block = ( props: Props ): JSX.Element | null => {
[ `${ parentClassName }__product-image` ]:
parentClassName,
},
borderProps.className
styleProps.className
) }
style={ {
...typographyProps.style,
...borderProps.style,
...spacingProps.style,
} }
>
<ImagePlaceholder />
</div>
@@ -141,26 +151,24 @@ export const Block = ( props: Props ): JSX.Element | null => {
{
[ `${ parentClassName }__product-image` ]: parentClassName,
},
borderProps.className
styleProps.className
) }
style={ {
...typographyProps.style,
...borderProps.style,
...spacingProps.style,
} }
>
<ParentComponent { ...( showProductLink && anchorProps ) }>
{ !! showSaleBadge && (
<ProductSaleBadge
align={ saleBadgeAlign }
product={ product }
{ ...restProps }
/>
) }
<Image
fallbackAlt={ product.name }
image={ image }
loaded={ ! isLoading }
showFullSize={ imageSizing !== 'cropped' }
showFullSize={ imageSizing !== ImageSizing.THUMBNAIL }
width={ width }
height={ height }
scale={ scale }
/>
</ParentComponent>
</div>

View File

@@ -32,22 +32,29 @@ import {
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import type { BlockAttributes } from './types';
import { BlockAttributes, ImageSizing } from './types';
import { ImageSizeSettings } from './image-size-settings';
type SaleBadgeAlignProps = 'left' | 'center' | 'right';
type ImageSizingProps = 'full-size' | 'cropped';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const { showProductLink, imageSizing, showSaleBadge, saleBadgeAlign } =
attributes;
const blockProps = useBlockProps();
const {
showProductLink,
imageSizing,
showSaleBadge,
saleBadgeAlign,
width,
height,
scale,
} = attributes;
const blockProps = useBlockProps( { style: { width, height } } );
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const isBlockThemeEnabled = getSettingWithCoercion(
'is_block_theme_enabled',
'isBlockThemeEnabled',
false,
isBoolean
);
@@ -57,15 +64,15 @@ const Edit = ( {
[ setAttributes, isDescendentOfQueryLoop ]
);
useEffect( () => {
if ( isBlockThemeEnabled && attributes.imageSizing !== 'full-size' ) {
setAttributes( { imageSizing: 'full-size' } );
}
}, [ attributes.imageSizing, isBlockThemeEnabled, setAttributes ] );
return (
<div { ...blockProps }>
<InspectorControls>
<ImageSizeSettings
scale={ scale }
width={ width }
height={ height }
setAttributes={ setAttributes }
/>
<PanelBody
title={ __( 'Content', 'woo-gutenberg-products-block' ) }
>
@@ -160,19 +167,19 @@ const Edit = ( {
}
) }
value={ imageSizing }
onChange={ ( value: ImageSizingProps ) =>
onChange={ ( value: ImageSizing ) =>
setAttributes( { imageSizing: value } )
}
>
<ToggleGroupControlOption
value="full-size"
value={ ImageSizing.SINGLE }
label={ __(
'Full Size',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="cropped"
value={ ImageSizing.THUMBNAIL }
label={ __(
'Cropped',
'woo-gutenberg-products-block'

View File

@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { BlockAttributes } from '@wordpress/blocks';
import {
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanel as ToolsPanel,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanelItem as ToolsPanelItem,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUnitControl as UnitControl,
} from '@wordpress/components';
interface ImageSizeSettingProps {
scale: string;
width: string | undefined;
height: string | undefined;
setAttributes: ( attrs: BlockAttributes ) => void;
}
const scaleHelp: Record< string, string > = {
cover: __(
'Image is scaled and cropped to fill the entire space without being distorted.',
'woo-gutenberg-products-block'
),
contain: __(
'Image is scaled to fill the space without clipping nor distorting.',
'woo-gutenberg-products-block'
),
fill: __(
'Image will be stretched and distorted to completely fill the space.',
'woo-gutenberg-products-block'
),
};
export const ImageSizeSettings = ( {
scale,
width,
height,
setAttributes,
}: ImageSizeSettingProps ) => {
return (
<ToolsPanel
className="wc-block-product-image__tools-panel"
label={ __( 'Image size', 'woo-gutenberg-products-block' ) }
>
<UnitControl
label={ __( 'Height', 'woo-gutenberg-products-block' ) }
onChange={ ( value: string ) => {
setAttributes( { height: value } );
} }
value={ height }
units={ [
{
value: 'px',
label: 'px',
},
] }
/>
<UnitControl
label={ __( 'Width', 'woo-gutenberg-products-block' ) }
onChange={ ( value: string ) => {
setAttributes( { width: value } );
} }
value={ width }
units={ [
{
value: 'px',
label: 'px',
},
] }
/>
{ height && (
<ToolsPanelItem
hasValue={ () => true }
label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
>
<ToggleGroupControl
label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
value={ scale }
help={ scaleHelp[ scale ] }
onChange={ ( value: string ) =>
setAttributes( {
scale: value,
} )
}
isBlock
>
<>
<ToggleGroupControlOption
value="cover"
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="contain"
label={ __(
'Contain',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="fill"
label={ __(
'Fill',
'woo-gutenberg-products-block'
) }
/>
</>
</ToggleGroupControl>
</ToolsPanelItem>
) }
</ToolsPanel>
);
};

View File

@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
textdomain: 'woo-gutenberg-products-block',
attributes,

View File

@@ -16,6 +16,7 @@
border-radius: inherit;
vertical-align: middle;
width: 100%;
height: auto;
&[hidden] {
display: none;
@@ -60,3 +61,7 @@
.wc-block-components-product-image {
margin: 0 0 $gap-small;
}
.wc-block-product-image__tools-panel .components-input-control {
margin-bottom: 8px;
}

View File

@@ -24,7 +24,6 @@ export const supports = {
spacing: {
margin: true,
padding: true,
__experimentalSkipSerialization: true,
},
} ),
__experimentalSelector: '.wc-block-components-product-image',

View File

@@ -9,19 +9,14 @@ import { ProductResponseItem } from '@woocommerce/types';
* Internal dependencies
*/
import { Block } from '../block';
import { ImageSizing } from '../types';
jest.mock( '@woocommerce/base-hooks', () => ( {
__esModule: true,
useBorderProps: jest.fn( () => ( {
useStyleProps: jest.fn( () => ( {
className: '',
style: {},
} ) ),
useTypographyProps: jest.fn( () => ( {
style: {},
} ) ),
useSpacingProps: jest.fn( () => ( {
style: {},
} ) ),
} ) );
const productWithoutImages: ProductResponseItem = {
@@ -152,7 +147,7 @@ describe( 'Product Image Block', () => {
productId={ productWithImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -186,7 +181,7 @@ describe( 'Product Image Block', () => {
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -219,7 +214,7 @@ describe( 'Product Image Block', () => {
productId={ productWithImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -249,7 +244,7 @@ describe( 'Product Image Block', () => {
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -277,7 +272,7 @@ describe( 'Product Image Block', () => {
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>

View File

@@ -1,3 +1,8 @@
export enum ImageSizing {
SINGLE = 'single',
THUMBNAIL = 'thumbnail',
}
export interface BlockAttributes {
// The product ID.
productId: number;
@@ -10,7 +15,13 @@ export interface BlockAttributes {
// How should the sale badge be aligned if displayed.
saleBadgeAlign: 'left' | 'center' | 'right';
// Size of image to use.
imageSizing: 'full-size' | 'cropped';
imageSizing: ImageSizing;
// Whether or not be a children of Query Loop Block.
isDescendentOfQueryLoop: boolean;
// Height of the image.
height?: string;
// Width of the image.
width?: string;
// Image scaling method.
scale: 'cover' | 'contain' | 'fill';
}

View File

@@ -8,11 +8,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { CurrencyCode } from '@woocommerce/type-defs/currency';
import type { HTMLAttributes } from 'react';
@@ -21,7 +17,6 @@ import type { HTMLAttributes } from 'react';
* Internal dependencies
*/
import type { BlockAttributes } from './types';
import './style.scss';
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
@@ -41,36 +36,36 @@ interface PriceProps {
export const Block = ( props: Props ): JSX.Element | null => {
const { className, textAlign, isDescendentOfSingleProductTemplate } = props;
const { parentClassName } = useInnerBlockLayoutContext();
const styleProps = useStyleProps( props );
const { parentName, parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const colorProps = useColorProps( props );
const spacingProps = useSpacingProps( props );
const typographyProps = useTypographyProps( props );
const isDescendentOfAllProductsBlock =
parentName === 'woocommerce/all-products';
const wrapperClassName = classnames(
'wc-block-components-product-price',
className,
colorProps.className,
styleProps.className,
{
[ `${ parentClassName }__product-price` ]: parentClassName,
},
typographyProps.className
}
);
if ( ! product.id && ! isDescendentOfSingleProductTemplate ) {
return (
const productPriceComponent = (
<ProductPrice align={ textAlign } className={ wrapperClassName } />
);
if ( isDescendentOfAllProductsBlock ) {
return (
<div className="wp-block-woocommerce-product-price">
{ productPriceComponent }
</div>
);
}
return productPriceComponent;
}
const style = {
...colorProps.style,
...typographyProps.style,
};
const spacingStyle = {
...spacingProps.style,
};
const prices: PriceProps = product.prices;
const currency = isDescendentOfSingleProductTemplate
? getCurrencyFromPriceResponse()
@@ -83,12 +78,13 @@ export const Block = ( props: Props ): JSX.Element | null => {
[ `${ parentClassName }__product-price__value--on-sale` ]: isOnSale,
} );
return (
const productPriceComponent = (
<ProductPrice
align={ textAlign }
className={ wrapperClassName }
regularPriceStyle={ style }
priceStyle={ style }
style={ styleProps.style }
regularPriceStyle={ styleProps.style }
priceStyle={ styleProps.style }
priceClassName={ priceClassName }
currency={ currency }
price={
@@ -109,9 +105,16 @@ export const Block = ( props: Props ): JSX.Element | null => {
[ `${ parentClassName }__product-price__regular` ]:
parentClassName,
} ) }
spacingStyle={ spacingStyle }
/>
);
if ( isDescendentOfAllProductsBlock ) {
return (
<div className="wp-block-woocommerce-product-price">
{ productPriceComponent }
</div>
);
}
return productPriceComponent;
};
export default ( props: Props ) => {

View File

@@ -8,12 +8,12 @@ import {
} from '@wordpress/block-editor';
import { useEffect } from '@wordpress/element';
import type { BlockAlignment } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import Block from './block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
type UnsupportedAligments = 'wide' | 'full';
type AllowedAlignments = Exclude< BlockAlignment, UnsupportedAligments >;
@@ -53,18 +53,12 @@ const PriceEdit = ( {
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const isDescendentOfSingleProductTemplate = useSelect(
( select ) => {
const store = select( 'core/edit-site' );
const postId = store?.getEditedPostId< string | undefined >();
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate( { isDescendentOfQueryLoop } );
return (
postId?.includes( '//single-product' ) &&
! isDescendentOfQueryLoop
);
},
[ isDescendentOfQueryLoop ]
);
if ( isDescendentOfQueryLoop ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect(
() =>

View File

@@ -1,7 +0,0 @@
.wc-block-components-product-price {
display: block;
.wc-block-all-products & {
margin-bottom: $gap-small;
}
}

View File

@@ -27,7 +27,8 @@ export const supports = {
__experimentalSkipSerialization: true,
__experimentalLetterSpacing: true,
},
__experimentalSelector: '.wc-block-components-product-price',
__experimentalSelector:
'.wp-block-woocommerce-product-price .wc-block-components-product-price',
} ),
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {

View File

@@ -3,11 +3,14 @@
"version": "1.0.0",
"icon": "info",
"title": "Product Details",
"description": "A block that allows your customers to see details and reviews about the product.",
"description": "Display a product's description, attributes, and reviews.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
"align": true,
"spacing": {
"margin": true
}
},
"textdomain": "woocommerce",
"apiVersion": 2,

View File

@@ -5,10 +5,6 @@ import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
interface SingleProductTab {
id: string;
title: string;

View File

@@ -2,19 +2,30 @@
* External dependencies
*/
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productDetails } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
icon: {
src: (
<Icon
icon={ productDetails }
className="wc-block-editor-components-block-icon"
/>
),
},
edit,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
icon,
// @ts-expect-error `edit` can be extended to include other attributes
edit,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -1,5 +1,8 @@
.woocommerce .wp-block-woocommerce-product-image-gallery {
position: relative;
// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
clear: both;
max-width: 512px;
span.onsale {
right: unset;
@@ -8,6 +11,14 @@
}
}
.woocommerce .wp-block-woocommerce-product-image-gallery .woocommerce-product-gallery.images {
width: initial;
// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
.woocommerce .wp-block-woocommerce-product-image-gallery::after {
clear: both;
content: "";
display: table;
}
.woocommerce .wp-block-woocommerce-product-image-gallery .woocommerce-product-gallery.images {
width: 100%;
}

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"title": "Product Meta",
"icon": "product",
"description": "Display Product Meta",
"description": "Display a products SKU, categories, tags, and more.",
"category": "woocommerce",
"supports": {
"align": true,

View File

@@ -8,8 +8,12 @@ import { InnerBlockTemplate } from '@wordpress/blocks';
* Internal dependencies
*/
import './editor.scss';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = () => {
const isDescendentOfSingleProductTemplate =
useIsDescendentOfSingleProductTemplate();
const TEMPLATE: InnerBlockTemplate[] = [
[
'core/group',
@@ -18,7 +22,7 @@ const Edit = () => {
[
'woocommerce/product-sku',
{
isDescendentOfSingleProductTemplate: true,
isDescendentOfSingleProductTemplate,
},
],
[

View File

@@ -1,13 +1,15 @@
/**
* External dependencies
*/
import { box as icon } from '@wordpress/icons';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productMeta } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockSingleProductTemplate( {
@@ -16,7 +18,16 @@ registerBlockSingleProductTemplate( {
blockMetadata: metadata,
blockSettings: {
edit,
icon,
save,
icon: {
src: (
<Icon
icon={ productMeta }
className="wc-block-editor-components-block-icon"
/>
),
},
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: true,
} );

View File

@@ -9,10 +9,6 @@ import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Notice } from '@wordpress/components';
/**
* Internal dependencies
*/
export const ProductReviews = () => {
const blockProps = useBlockProps();

View File

@@ -8,6 +8,7 @@ import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
@@ -15,6 +16,6 @@ registerBlockSingleProductTemplate( {
blockMetadata: metadata,
blockSettings: {
edit,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -0,0 +1,38 @@
{
"name": "woocommerce/product-rating-counter",
"version": "1.0.0",
"title": "Product Rating Counter",
"description": "Display the review count of a product",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
import { Disabled } from '@wordpress/components';
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
const reviewsCount = reviews
? sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woo-gutenberg-products-block'
),
reviews
)
: __( '(X customer reviews)', 'woo-gutenberg-products-block' );
return (
<span className="wc-block-components-product-rating-counter__reviews_count">
<Disabled>
<a href="/">{ reviewsCount }</a>
</Disabled>
</span>
);
};
type ProductRatingCounterProps = {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};
export const Block = (
props: ProductRatingCounterProps
): JSX.Element | undefined => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating-counter',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-counter__container">
<ReviewsCount reviews={ reviews } />
</div>
</div>
);
}
};
export default withProductDataContext( Block );

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating-counter',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starFilled }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );

View File

@@ -0,0 +1,24 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: false,
background: false,
link: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating-counter',
} ),
};

View File

@@ -0,0 +1,7 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}

View File

@@ -0,0 +1,38 @@
{
"name": "woocommerce/product-rating-stars",
"version": "1.0.0",
"title": "Product Rating Stars",
"description": "Display the average rating of a product with stars",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,158 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import './style.scss';
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ classnames(
'wc-block-components-product-rating-stars__norating-container',
`${ parentClassName }-product-rating-stars__norating-container`
) }
>
<div
className={
'wc-block-components-product-rating-stars__norating'
}
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woo-gutenberg-products-block'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ classnames(
'wc-block-components-product-rating-stars__stars',
`${ parentClassName }__product-rating-stars__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
interface ProductRatingStarsProps {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
}
export const Block = ( props: ProductRatingStarsProps ): JSX.Element | null => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating-stars',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-stars__container">
{ content }
</div>
</div>
);
};
export default withProductDataContext( Block );

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starFilled }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );

View File

@@ -0,0 +1,107 @@
.wc-block-components-product-rating-stars {
display: block;
line-height: 1;
&__stars {
display: inline-block;
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
text-align: left;
&::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
opacity: 0.5;
color: inherit;
white-space: nowrap;
}
span {
overflow: hidden;
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
padding-top: 1.5em;
}
span::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
white-space: nowrap;
}
}
&__link {
display: inline-block;
height: 1.618em;
width: 100%;
text-align: inherit;
@include font-size(small);
}
.wc-block-all-products & {
margin-top: 0;
margin-bottom: $gap-small;
}
&__norating-container {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: $gap-smaller;
}
&__norating {
display: inline-block;
overflow: hidden;
position: relative;
width: 1.5em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
-webkit-text-stroke: 2px var(--wp--preset--color--black, #000);
&::before {
content: "\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: transparent;
white-space: nowrap;
text-align: center;
}
}
}
.wp-block-woocommerce-single-product {
.wc-block-components-product-rating__stars {
margin: 0;
}
}
.wc-block-all-products,
.wp-block-query {
.is-loading {
.wc-block-components-product-rating {
@include placeholder();
width: 7em;
}
}
}

View File

@@ -0,0 +1,25 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: false,
link: false,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating',
} ),
};

View File

@@ -0,0 +1,7 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}

View File

@@ -1,25 +0,0 @@
/**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
textAlign: {
type: 'string',
default: '',
},
isDescendentOfSingleProductBlock: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;

View File

@@ -0,0 +1,38 @@
{
"name": "woocommerce/product-rating",
"version": "1.0.0",
"icon": "info",
"title": "Product Rating",
"description": "Display the average rating of a product.",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -7,11 +7,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
@@ -20,21 +16,12 @@ import { isNumber, ProductResponseItem } from '@woocommerce/types';
*/
import './style.scss';
type Props = {
textAlign?: string;
className?: string;
};
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
type AddReviewProps = {
href?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
@@ -45,11 +32,6 @@ const getAverageRating = (
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getReviewsHref = ( product: ProductResponseItem ) => {
const { permalink } = product;
return `${ permalink }#reviews`;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
@@ -58,12 +40,35 @@ const getRatingCount = ( product: ProductResponseItem ) => {
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ classnames(
'wc-block-components-product-rating__norating-container',
`${ parentClassName }-product-rating__norating-container`
) }
>
<div
className={ 'wc-block-components-product-rating__norating' }
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = {
width: ( rating / 5 ) * 100 + '%',
};
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
@@ -98,45 +103,60 @@ const Rating = ( props: RatingProps ): JSX.Element => {
);
};
const AddReview = ( props: AddReviewProps ): JSX.Element | null => {
const { href } = props;
const label = __( 'Add review', 'woo-gutenberg-products-block' );
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
return href ? (
<a className="wc-block-components-product-rating__link" href={ href }>
{ label }
</a>
) : null;
const reviewsCount = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woo-gutenberg-products-block'
),
reviews
);
return (
<span className="wc-block-components-product-rating__reviews_count">
{ reviewsCount }
</span>
);
};
/**
* Product Rating Block Component.
*
* @param {Object} props Incoming props.
* @param {string} [props.className] CSS Class name for the component.
* @param {string} [props.textAlign] Text alignment.
*
* @return {*} The component.
*/
export const Block = ( props: Props ): JSX.Element | null => {
const { textAlign } = props;
type ProductRatingProps = {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};
export const Block = ( props: ProductRatingProps ): JSX.Element | undefined => {
const {
textAlign,
isDescendentOfSingleProductBlock,
shouldDisplayMockedReviewsWhenProductHasNoReviews,
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
const reviews = getRatingCount( product );
const href = getReviewsHref( product );
const className = classnames(
colorProps.className,
styleProps.className,
'wc-block-components-product-rating',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
@@ -145,21 +165,21 @@ export const Block = ( props: Props ): JSX.Element | null => {
parentClassName={ parentClassName }
/>
) : (
<AddReview href={ href } />
mockedRatings
);
return (
<div
className={ className }
style={ {
...colorProps.style,
...typographyProps.style,
...spacingProps.style,
} }
>
{ content }
</div>
);
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating__container">
{ content }
{ reviews && isDescendentOfSingleProductBlock ? (
<ReviewsCount reviews={ reviews } />
) : null }
</div>
</div>
);
}
};
export default withProductDataContext( Block );

View File

@@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
@@ -15,29 +14,47 @@ import { ProductQueryContext as Context } from '@woocommerce/blocks/product-quer
* Internal dependencies
*/
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import { BLOCK_TITLE, BLOCK_ICON } from './constants';
import { BlockAttributes } from './types';
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
);
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
@@ -55,11 +72,5 @@ const Edit = ( {
</>
);
};
export default withProductSelector( {
icon: BLOCK_ICON,
label: BLOCK_TITLE,
description: __(
'Choose a product to display its rating.',
'woo-gutenberg-products-block'
),
} )( Edit );
export default Edit;

View File

@@ -1,37 +1,34 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import { BLOCK_ICON as icon } from './constants';
import metadata from './block.json';
import { supports } from './support';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
usesContext: [ 'query', 'queryId', 'postId' ],
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
icon: { src: icon },
attributes,
supports,
edit,
};
registerBlockType( 'woocommerce/product-rating', { ...blockConfig } );
registerBlockSingleProductTemplate( {
blockName: 'woocommerce/product-rating',
blockMetadata: metadata,
blockSettings: blockConfig,
isAvailableOnPostEditor: true,
} );

View File

@@ -1,75 +1,12 @@
.wc-block-components-product-rating {
display: block;
line-height: 1;
&__stars {
display: inline-block;
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
&::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
opacity: 0.5;
color: inherit;
white-space: nowrap;
}
span {
overflow: hidden;
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
padding-top: 1.5em;
}
span::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
white-space: nowrap;
.wc-block-components-product-rating__container {
> * {
vertical-align: middle;
}
}
&__link {
display: inline-block;
height: 1.618em;
width: 100%;
text-align: inherit;
@include font-size(small);
}
.wc-block-all-products & {
margin-top: 0;
margin-bottom: $gap-small;
}
}
.wc-block-single-product {
.wc-block-components-product-rating__stars {
display: inline-block;
margin: 0;
}
}
.wc-block-all-products,
.wp-block-query {
.is-loading {
.wc-block-components-product-rating {
@include placeholder();
width: 7em;
}
}
}

View File

@@ -1,5 +1,7 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}

View File

@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
icon,
edit,
save,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -8,12 +8,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useBorderProps,
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
@@ -27,12 +22,9 @@ type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const { className, align } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const borderProps = useBorderProps( props );
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
if ( ! product.id || ! product.on_sale ) {
return null;
@@ -52,16 +44,9 @@ export const Block = ( props: Props ): JSX.Element | null => {
{
[ `${ parentClassName }__product-onsale` ]: parentClassName,
},
colorProps.className,
borderProps.className,
typographyProps.className
styleProps.className
) }
style={ {
...colorProps.style,
...borderProps.style,
...typographyProps.style,
...spacingProps.style,
} }
style={ styleProps.style }
>
<Label
label={ __( 'Sale', 'woo-gutenberg-products-block' ) }

View File

@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
};

View File

@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
interface UseIsDescendentOfSingleProductBlockProps {
blockClientId: string;
}
export const useIsDescendentOfSingleProductBlock = ( {
blockClientId,
}: UseIsDescendentOfSingleProductBlockProps ) => {
const { isDescendentOfSingleProductBlock } = useSelect(
( select ) => {
const { getBlockParentsByBlockName } =
select( 'core/block-editor' );
const blockParentBlocksIds = getBlockParentsByBlockName(
blockClientId?.replace( 'block-', '' ),
[ 'woocommerce/single-product' ]
);
return {
isDescendentOfSingleProductBlock:
blockParentBlocksIds.length > 0,
};
},
[ blockClientId ]
);
return { isDescendentOfSingleProductBlock };
};

View File

@@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
export const useIsDescendentOfSingleProductTemplate = () => {
const isDescendentOfSingleProductTemplate = useSelect( ( select ) => {
const store = select( 'core/edit-site' );
const postId = store?.getEditedPostId< string | undefined >();
return Boolean( postId?.includes( '//single-product' ) );
}, [] );
return { isDescendentOfSingleProductTemplate };
};

View File

@@ -9,11 +9,7 @@ import {
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
import {
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@@ -47,14 +43,11 @@ const Preview = ( {
const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const sku = product.sku;
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
if ( props.isDescendentOfSingleProductTemplate ) {
return (
<Preview
@@ -78,16 +71,10 @@ const Block = ( props: Props ): JSX.Element | null => {
className: classnames(
className,
'wc-block-components-product-sku wp-block-woocommerce-product-sku',
{
[ colorProps.className ]: colorProps.className,
[ typographyProps.className ]:
typographyProps.className,
}
styleProps.className
),
style: {
...colorProps.style,
...typographyProps.style,
...spacingProps.style,
...styleProps.style,
},
} ) }
/>

View File

@@ -12,6 +12,8 @@ import { useEffect } from '@wordpress/element';
*/
import Block from './block';
import type { Attributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = ( {
attributes,
@@ -27,10 +29,29 @@ const Edit = ( {
...context,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( { blockClientId: blockProps.id } );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
() =>
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
isDescendentOfSingleProductBlock,
} ),
[
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
isDescendentOfSingleProductBlock,
]
);
return (

View File

@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
'woocommerce/product-meta',
],
edit,

View File

@@ -2,6 +2,7 @@ export interface Attributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductTemplate: boolean;
isDescendentOfSingleProductBlock: boolean;
showProductSelector: boolean;
isDescendantOfAllProducts: boolean;
}

View File

@@ -12,6 +12,10 @@ export const blockAttributes: BlockAttributes = {
type: 'boolean',
default: false,
},
isDescendantOfAllProducts: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;

View File

@@ -7,7 +7,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useColorProps, useTypographyProps } from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
@@ -17,34 +17,54 @@ import type { HTMLAttributes } from 'react';
import './style.scss';
import type { BlockAttributes } from './types';
const lowStockText = ( lowStock: number ): string => {
return sprintf(
/* translators: %d stock amount (number of items in stock for product) */
__( '%d left in stock', 'woo-gutenberg-products-block' ),
lowStock
);
};
const stockText = ( inStock: boolean, isBackordered: boolean ): string => {
if ( isBackordered ) {
/**
* Get stock text based on stock. For example:
* - In stock
* - Out of stock
* - Available on backorder
* - 2 left in stock
*
* @param stockInfo Object containing stock information.
* @param stockInfo.isInStock Whether product is in stock.
* @param stockInfo.isLowStock Whether product is low in stock.
* @param stockInfo.lowStockAmount Number of items left in stock.
* @param stockInfo.isOnBackorder Whether product is on backorder.
* @return string Stock text.
*/
const getTextBasedOnStock = ( {
isInStock = false,
isLowStock = false,
lowStockAmount = null,
isOnBackorder = false,
}: {
isInStock?: boolean;
isLowStock?: boolean;
lowStockAmount?: number | null;
isOnBackorder?: boolean;
} ): string => {
if ( isLowStock && lowStockAmount !== null ) {
return sprintf(
/* translators: %d stock amount (number of items in stock for product) */
__( '%d left in stock', 'woo-gutenberg-products-block' ),
lowStockAmount
);
} else if ( isOnBackorder ) {
return __( 'Available on backorder', 'woo-gutenberg-products-block' );
} else if ( isInStock ) {
return __( 'In stock', 'woo-gutenberg-products-block' );
}
return inStock
? __( 'In Stock', 'woo-gutenberg-products-block' )
: __( 'Out of Stock', 'woo-gutenberg-products-block' );
return __( 'Out of stock', 'woo-gutenberg-products-block' );
};
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
if ( ! product.id || ! product.is_purchasable ) {
if ( ! product.id ) {
return null;
}
@@ -54,28 +74,34 @@ export const Block = ( props: Props ): JSX.Element | null => {
return (
<div
className={ classnames(
className,
colorProps.className,
'wc-block-components-product-stock-indicator',
{
[ `${ parentClassName }__stock-indicator` ]:
parentClassName,
'wc-block-components-product-stock-indicator--in-stock':
inStock,
'wc-block-components-product-stock-indicator--out-of-stock':
! inStock,
'wc-block-components-product-stock-indicator--low-stock':
!! lowStock,
'wc-block-components-product-stock-indicator--available-on-backorder':
!! isBackordered,
}
) }
style={ { ...colorProps.style, ...typographyProps.style } }
className={ classnames( className, {
[ `${ parentClassName }__stock-indicator` ]: parentClassName,
'wc-block-components-product-stock-indicator--in-stock':
inStock,
'wc-block-components-product-stock-indicator--out-of-stock':
! inStock,
'wc-block-components-product-stock-indicator--low-stock':
!! lowStock,
'wc-block-components-product-stock-indicator--available-on-backorder':
!! isBackordered,
// When inside All products block
...( props.isDescendantOfAllProducts && {
[ styleProps.className ]: styleProps.className,
'wc-block-components-product-stock-indicator wp-block-woocommerce-product-stock-indicator':
true,
} ),
} ) }
// When inside All products block
{ ...( props.isDescendantOfAllProducts && {
style: styleProps.style,
} ) }
>
{ lowStock
? lowStockText( lowStock )
: stockText( inStock, isBackordered ) }
{ getTextBasedOnStock( {
isInStock: inStock,
isLowStock: !! lowStock,
lowStockAmount: lowStock,
isOnBackorder: isBackordered,
} ) }
</div>
);
};

View File

@@ -24,7 +24,9 @@ const Edit = ( {
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const blockProps = useBlockProps();
const { style, ...blockProps } = useBlockProps( {
className: 'wc-block-components-product-stock-indicator',
} );
const blockAttrs = {
...attributes,
@@ -38,7 +40,15 @@ const Edit = ( {
);
return (
<div { ...blockProps }>
<div
{ ...blockProps }
/**
* If block is decendant of the All Products block, we don't want to
* apply style here because it will be applied inside Block using
* useColors, useTypography, and useSpacing hooks.
*/
style={ attributes.isDescendantOfAllProducts ? undefined : style }
>
<EditProductLink />
<Block { ...blockAttrs } />
</div>

View File

@@ -32,6 +32,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
};

View File

@@ -2,17 +2,39 @@
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import {
// @ts-expect-error We check if this exists before using it.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalGetSpacingClassesAndStyles,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: false,
link: false,
...sharedConfig.supports,
color: {
text: true,
background: true,
},
typography: {
fontSize: true,
lineHeight: true,
...( isFeaturePluginBuild() && {
__experimentalFontWeight: true,
__experimentalFontFamily: true,
__experimentalFontStyle: true,
__experimentalTextTransform: true,
__experimentalTextDecoration: true,
__experimentalLetterSpacing: true,
} ),
},
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
},
__experimentalSelector: '.wc-block-components-product-stock-indicator',
} ),
};

View File

@@ -1,4 +1,5 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendantOfAllProducts: boolean;
}

View File

@@ -9,7 +9,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useColorProps, useTypographyProps } from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
@@ -25,8 +25,7 @@ const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const styleProps = useStyleProps( props );
if ( ! product ) {
return (
@@ -55,7 +54,7 @@ const Block = ( props: Props ): JSX.Element | null => {
<Summary
className={ classnames(
className,
colorProps.className,
styleProps.className,
`wc-block-components-product-summary`,
{
[ `${ parentClassName }__product-summary` ]:
@@ -65,10 +64,7 @@ const Block = ( props: Props ): JSX.Element | null => {
source={ source }
maxLength={ 150 }
countType={ blocksConfig.wordCountType || 'words' }
style={ {
...colorProps.style,
...typographyProps.style,
} }
style={ styleProps.style }
/>
);
};

View File

@@ -10,11 +10,7 @@ import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import ProductName from '@woocommerce/base-components/product-name';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import {
useSpacingProps,
useTypographyProps,
useColorProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import type { HTMLAttributes } from 'react';
/**
@@ -60,22 +56,18 @@ export const Block = ( props: Props ): JSX.Element => {
linkTarget,
align,
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const { dispatchStoreEvent } = useStoreEvents();
const colorProps = useColorProps( props );
const spacingProps = useSpacingProps( props );
const typographyProps = useTypographyProps( props );
if ( ! product.id ) {
return (
<TagName
headingLevel={ headingLevel }
className={ classnames(
className,
colorProps.className,
styleProps.className,
'wc-block-components-product-title',
{
[ `${ parentClassName }__product-title` ]:
@@ -84,15 +76,7 @@ export const Block = ( props: Props ): JSX.Element => {
align && isFeaturePluginBuild(),
}
) }
style={
isFeaturePluginBuild()
? {
...spacingProps.style,
...typographyProps.style,
...colorProps.style,
}
: {}
}
style={ isFeaturePluginBuild() ? styleProps.style : {} }
/>
);
}
@@ -102,7 +86,7 @@ export const Block = ( props: Props ): JSX.Element => {
headingLevel={ headingLevel }
className={ classnames(
className,
colorProps.className,
styleProps.className,
'wc-block-components-product-title',
{
[ `${ parentClassName }__product-title` ]: parentClassName,
@@ -110,15 +94,7 @@ export const Block = ( props: Props ): JSX.Element => {
align && isFeaturePluginBuild(),
}
) }
style={
isFeaturePluginBuild()
? {
...spacingProps.style,
...typographyProps.style,
...colorProps.style,
}
: {}
}
style={ isFeaturePluginBuild() ? styleProps.style : {} }
>
<ProductName
disabled={ ! showProductLink }

View File

@@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { isNumber } from '@woocommerce/types';
import {
BlockAttributes,
BlockConfiguration,
@@ -16,8 +17,10 @@ import { subscribe, select } from '@wordpress/data';
// Creating a local cache to prevent multiple registration tries.
const blocksRegistered = new Set();
function parseTemplateId( templateId: string | undefined ) {
return templateId?.split( '//' )[ 1 ];
function parseTemplateId( templateId: string | number | undefined ) {
// With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230
const parsedTemplateId = isNumber( templateId ) ? undefined : templateId;
return parsedTemplateId?.split( '//' )[ 1 ];
}
export const registerBlockSingleProductTemplate = ( {
@@ -26,10 +29,12 @@ export const registerBlockSingleProductTemplate = ( {
blockSettings,
isVariationBlock = false,
variationName,
isAvailableOnPostEditor,
}: {
blockName: string;
blockMetadata: Partial< BlockConfiguration >;
blockSettings: Partial< BlockConfiguration >;
isAvailableOnPostEditor: boolean;
isVariationBlock?: boolean;
variationName?: string;
} ) => {
@@ -38,7 +43,11 @@ export const registerBlockSingleProductTemplate = ( {
subscribe( () => {
const previousTemplateId = currentTemplateId;
const store = select( 'core/edit-site' );
currentTemplateId = parseTemplateId( store?.getEditedPostId() );
// With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230
currentTemplateId = parseTemplateId(
store?.getEditedPostId() as string | number | undefined
);
const hasChangedTemplate = previousTemplateId !== currentTemplateId;
const hasTemplateId = Boolean( currentTemplateId );
@@ -97,7 +106,7 @@ export const registerBlockSingleProductTemplate = ( {
// This subscribe callback could be invoked with the core/blocks store
// which would cause infinite registration loops because of the `registerBlockType` call.
// This local cache helps prevent that.
if ( ! isBlockRegistered ) {
if ( ! isBlockRegistered && isAvailableOnPostEditor ) {
if ( isVariationBlock ) {
blocksRegistered.add( variationName );
registerBlockVariation(

View File

@@ -1,7 +1,7 @@
.wc-block-components-button:not(.is-link) {
align-items: center;
display: inline-flex;
min-height: 3em;
height: auto;
justify-content: center;
text-align: center;
position: relative;
@@ -57,12 +57,7 @@
}
body:not(.woocommerce-block-theme-has-button-styles) .wc-block-components-button:not(.is-link) {
@include reset-typography();
font-weight: bold;
line-height: 1;
padding: 0 em($gap);
text-decoration: none;
text-transform: none;
min-height: 3em;
&:focus {
box-shadow: 0 0 0 2px $studio-blue;
@@ -77,21 +72,4 @@ body:not(.woocommerce-block-theme-has-button-styles) .wc-block-components-button
opacity: 0.9;
}
}
&.contained {
background-color: $gray-900;
color: $white;
&:disabled,
&:hover,
&:focus,
&:active {
background-color: $gray-900;
color: $white;
}
&:hover {
opacity: 0.9;
}
}
}

View File

@@ -1,7 +1,11 @@
/**
* External dependencies
*/
import { ValidatedTextInput, isPostcode } from '@woocommerce/blocks-checkout';
import {
ValidatedTextInput,
isPostcode,
type ValidatedTextInputHandle,
} from '@woocommerce/blocks-checkout';
import {
BillingCountryInput,
ShippingCountryInput,
@@ -10,178 +14,110 @@ import {
BillingStateInput,
ShippingStateInput,
} from '@woocommerce/base-components/state-input';
import { useEffect, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useEffect, useMemo, useRef } from '@wordpress/element';
import { withInstanceId } from '@wordpress/compose';
import { useShallowEqual } from '@woocommerce/base-hooks';
import {
AddressField,
AddressFields,
AddressType,
defaultAddressFields,
ShippingAddress,
} from '@woocommerce/settings';
import { useSelect, useDispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { FieldValidationStatus } from '@woocommerce/types';
import { defaultAddressFields } from '@woocommerce/settings';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import {
AddressFormProps,
FieldType,
FieldConfig,
AddressFormFields,
} from './types';
import prepareAddressFields from './prepare-address-fields';
import validateShippingCountry from './validate-shipping-country';
import customValidationHandler from './custom-validation-handler';
// If it's the shipping address form and the user starts entering address
// values without having set the country first, show an error.
const validateShippingCountry = (
values: ShippingAddress,
setValidationErrors: (
errors: Record< string, FieldValidationStatus >
) => void,
clearValidationError: ( error: string ) => void,
hasValidationError: boolean
): void => {
const validationErrorId = 'shipping_country';
if (
! hasValidationError &&
! values.country &&
( values.city || values.state || values.postcode )
) {
setValidationErrors( {
[ validationErrorId ]: {
message: __(
'Please select a country to calculate rates.',
'woo-gutenberg-products-block'
),
hidden: false,
},
} );
}
if ( hasValidationError && values.country ) {
clearValidationError( validationErrorId );
}
};
interface AddressFormProps {
// Id for component.
id?: string;
// Unique id for form.
instanceId: string;
// Array of fields in form.
fields: ( keyof AddressFields )[];
// Field configuration for fields in form.
fieldConfig?: Record< keyof AddressFields, Partial< AddressField > >;
// Function to all for an form onChange event.
onChange: ( newValue: ShippingAddress ) => void;
// Type of form.
type?: AddressType;
// Values for fields.
values: ShippingAddress;
}
const defaultFields = Object.keys(
defaultAddressFields
) as unknown as FieldType[];
/**
* Checkout address form.
*/
const AddressForm = ( {
id = '',
fields = Object.keys(
defaultAddressFields
) as unknown as ( keyof AddressFields )[],
fieldConfig = {} as Record< keyof AddressFields, Partial< AddressField > >,
fields = defaultFields,
fieldConfig = {} as FieldConfig,
instanceId,
onChange,
type = 'shipping',
values,
}: AddressFormProps ): JSX.Element => {
const validationErrorId = 'shipping_country';
const { setValidationErrors, clearValidationError } =
useDispatch( VALIDATION_STORE_KEY );
const countryValidationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return store.getValidationError( validationErrorId );
} );
// Track incoming props.
const currentFields = useShallowEqual( fields );
const currentFieldConfig = useShallowEqual( fieldConfig );
const currentCountry = useShallowEqual( values.country );
const addressFormFields = useMemo( () => {
return prepareAddressFields(
// Memoize the address form fields passed in from the parent component.
const addressFormFields = useMemo( (): AddressFormFields => {
const preparedFields = prepareAddressFields(
currentFields,
fieldConfig,
values.country
currentFieldConfig,
currentCountry
);
}, [ currentFields, fieldConfig, values.country ] );
return {
fields: preparedFields,
type,
required: preparedFields.filter( ( field ) => field.required ),
hidden: preparedFields.filter( ( field ) => field.hidden ),
};
}, [ currentFields, currentFieldConfig, currentCountry, type ] );
// Stores refs for rendered fields so we can access them later.
const fieldsRef = useRef<
Record< string, ValidatedTextInputHandle | null >
>( {} );
// Clear values for hidden fields.
useEffect( () => {
addressFormFields.forEach( ( field ) => {
if ( field.hidden && values[ field.key ] ) {
onChange( {
...values,
[ field.key ]: '',
} );
}
} );
}, [ addressFormFields, onChange, values ] );
const newValues = {
...values,
...Object.fromEntries(
addressFormFields.hidden.map( ( field ) => [ field.key, '' ] )
),
};
if ( ! isShallowEqual( values, newValues ) ) {
onChange( newValues );
}
}, [ onChange, addressFormFields, values ] );
// Maybe validate country when other fields change so user is notified that it's required.
useEffect( () => {
if ( type === 'shipping' ) {
validateShippingCountry(
values,
setValidationErrors,
clearValidationError,
!! countryValidationError?.message &&
! countryValidationError?.hidden
);
validateShippingCountry( values );
}
}, [
values,
countryValidationError?.message,
countryValidationError?.hidden,
setValidationErrors,
clearValidationError,
type,
] );
}, [ values, type ] );
// Changing country may change format for postcodes.
useEffect( () => {
fieldsRef.current?.postcode?.revalidate();
}, [ currentCountry ] );
id = id || instanceId;
/**
* Custom validation handler for fields with field specific handling.
*/
const customValidationHandler = (
inputObject: HTMLInputElement,
field: string,
customValues: {
country: string;
}
): boolean => {
if (
field === 'postcode' &&
customValues.country &&
! isPostcode( {
postcode: inputObject.value,
country: customValues.country,
} )
) {
inputObject.setCustomValidity(
__(
'Please enter a valid postcode',
'woo-gutenberg-products-block'
)
);
return false;
}
return true;
};
return (
<div id={ id } className="wc-block-components-address-form">
{ addressFormFields.map( ( field ) => {
{ addressFormFields.fields.map( ( field ) => {
if ( field.hidden ) {
return null;
}
// Create a consistent error ID based on the field key and type
const errorId = `${ type }_${ field.key }`;
const fieldProps = {
id: `${ id }-${ field.key }`,
errorId: `${ type }_${ field.key }`,
label: field.required ? field.label : field.optionalLabel,
autoCapitalize: field.autocapitalize,
autoComplete: field.autocomplete,
errorMessage: field.errorMessage,
required: field.required,
className: `wc-block-components-address-form__${ field.key }`,
};
if ( field.key === 'country' ) {
const Tag =
@@ -191,24 +127,26 @@ const AddressForm = ( {
return (
<Tag
key={ field.key }
id={ `${ id }-${ field.key }` }
errorId={ errorId }
label={
field.required
? field.label
: field.optionalLabel
}
{ ...fieldProps }
value={ values.country }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
onChange={ ( newCountry ) => {
const newValues = {
...values,
country: newValue,
country: newCountry,
state: '',
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
};
// Country will impact postcode too. Do we need to clear it?
if (
values.postcode &&
! isPostcode( {
postcode: values.postcode,
country: newCountry,
} )
) {
newValues.postcode = '';
}
onChange( newValues );
} }
/>
);
}
@@ -221,24 +159,15 @@ const AddressForm = ( {
return (
<Tag
key={ field.key }
id={ `${ id }-${ field.key }` }
errorId={ errorId }
{ ...fieldProps }
country={ values.country }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.state }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
state: newValue,
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
@@ -246,24 +175,23 @@ const AddressForm = ( {
return (
<ValidatedTextInput
key={ field.key }
id={ `${ id }-${ field.key }` }
errorId={ errorId }
className={ `wc-block-components-address-form__${ field.key }` }
label={
field.required ? field.label : field.optionalLabel
ref={ ( el ) =>
( fieldsRef.current[ field.key ] = el )
}
{ ...fieldProps }
value={ values[ field.key ] }
autoCapitalize={ field.autocapitalize }
autoComplete={ field.autocomplete }
onChange={ ( newValue: string ) =>
onChange( {
...values,
[ field.key ]:
field.key === 'postcode'
? newValue.trimStart().toUpperCase()
: newValue,
[ field.key ]: newValue,
} )
}
customFormatter={ ( value: string ) => {
if ( field.key === 'postcode' ) {
return value.trimStart().toUpperCase();
}
return value;
} }
customValidation={ ( inputObject: HTMLInputElement ) =>
customValidationHandler(
inputObject,
@@ -271,8 +199,6 @@ const AddressForm = ( {
values
)
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
} ) }

View File

@@ -0,0 +1,41 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { isPostcode } from '@woocommerce/blocks-checkout';
/**
* Custom validation handler for fields with field specific handling.
*/
const customValidationHandler = (
inputObject: HTMLInputElement,
field: string,
customValues: {
country: string;
}
): boolean => {
// Pass validation if the field is not required and is empty.
if ( ! inputObject.required && ! inputObject.value ) {
return true;
}
if (
field === 'postcode' &&
customValues.country &&
! isPostcode( {
postcode: inputObject.value,
country: customValues.country,
} )
) {
inputObject.setCustomValidity(
__(
'Please enter a valid postcode',
'woo-gutenberg-products-block'
)
);
return false;
}
return true;
};
export default customValidationHandler;

View File

@@ -8,20 +8,12 @@ import {
AddressFields,
CountryAddressFields,
defaultAddressFields,
getSetting,
KeyedAddressField,
LocaleSpecificAddressField,
} from '@woocommerce/settings';
import { __, sprintf } from '@wordpress/i18n';
import { isNumber, isString } from '@woocommerce/types';
/**
* This is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
* but we can import part of it to set which fields are required.
*
* This supports new properties such as optionalLabel which are not used by core (yet).
*/
const coreLocale = getSetting< CountryAddressFields >( 'countryLocale', {} );
import { COUNTRY_LOCALE } from '@woocommerce/block-settings';
/**
* Gets props from the core locale, then maps them to the shape we require in the client.
@@ -72,7 +64,15 @@ const getSupportedCoreLocaleProps = (
return fields;
};
const countryAddressFields: CountryAddressFields = Object.entries( coreLocale )
/**
* COUNTRY_LOCALE is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
* but we can import part of it to set which fields are required.
*
* This supports new properties such as optionalLabel which are not used by core (yet).
*/
const countryAddressFields: CountryAddressFields = Object.entries(
COUNTRY_LOCALE
)
.map( ( [ country, countryLocale ] ) => [
country,
Object.entries( countryLocale )

View File

@@ -0,0 +1,41 @@
/**
* External dependencies
*/
import type {
AddressField,
AddressFields,
AddressType,
ShippingAddress,
KeyedAddressField,
} from '@woocommerce/settings';
export type FieldConfig = Record<
keyof AddressFields,
Partial< AddressField >
>;
export type FieldType = keyof AddressFields;
export type AddressFormFields = {
fields: KeyedAddressField[];
type: AddressType;
required: KeyedAddressField[];
hidden: KeyedAddressField[];
};
export interface AddressFormProps {
// Id for component.
id?: string;
// Unique id for form.
instanceId: string;
// Type of form (billing or shipping).
type?: AddressType;
// Array of fields in form.
fields: FieldType[];
// Field configuration for fields in form.
fieldConfig?: FieldConfig;
// Called with the new address data when the address form changes. This is only called when all required fields are filled and there are no validation errors.
onChange: ( newValue: ShippingAddress ) => void;
// Values for fields.
values: ShippingAddress;
}

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { type ShippingAddress } from '@woocommerce/settings';
import { select, dispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
// If it's the shipping address form and the user starts entering address
// values without having set the country first, show an error.
const validateShippingCountry = ( values: ShippingAddress ): void => {
const validationErrorId = 'shipping_country';
const hasValidationError =
select( VALIDATION_STORE_KEY ).getValidationError( validationErrorId );
if (
! values.country &&
( values.city || values.state || values.postcode )
) {
if ( hasValidationError ) {
dispatch( VALIDATION_STORE_KEY ).showValidationError(
validationErrorId
);
} else {
dispatch( VALIDATION_STORE_KEY ).setValidationErrors( {
[ validationErrorId ]: {
message: __(
'Please select your country',
'woo-gutenberg-products-block'
),
hidden: false,
},
} );
}
}
if ( hasValidationError && values.country ) {
dispatch( VALIDATION_STORE_KEY ).clearValidationError(
validationErrorId
);
}
};
export default validateShippingCountry;

View File

@@ -11,6 +11,7 @@ import type { RefObject } from 'react';
* Internal dependencies
*/
import CartLineItemRow from './cart-line-item-row';
import './style.scss';
const placeholderRows = [ ...Array( 3 ) ].map( ( _x, i ) => (
<CartLineItemRow lineItem={ {} } key={ i } />

View File

@@ -0,0 +1,141 @@
table.wc-block-cart-items,
table.wc-block-cart-items th,
table.wc-block-cart-items td {
// Override Storefront theme gray table background.
background: none !important;
// Remove borders on default themes.
border: 0;
margin: 0;
}
.editor-styles-wrapper table.wc-block-cart-items,
table.wc-block-cart-items {
width: 100%;
.wc-block-cart-items__header {
@include font-size( smaller );
text-transform: uppercase;
.wc-block-cart-items__header-image {
width: 100px;
}
.wc-block-cart-items__header-product {
visibility: hidden;
}
.wc-block-cart-items__header-total {
width: 100px;
text-align: right;
}
}
.wc-block-cart-items__row {
.wc-block-cart-item__image img {
width: 100%;
margin: 0;
}
.wc-block-cart-item__quantity {
.wc-block-cart-item__remove-link {
@include link-button;
@include font-size( smaller );
text-transform: none;
white-space: nowrap;
}
}
.wc-block-components-product-name {
display: block;
max-width: max-content;
}
.wc-block-cart-item__total {
@include font-size( regular );
text-align: right;
line-height: inherit;
}
.wc-block-components-product-metadata {
margin-bottom: 0.75em;
}
&.is-disabled {
opacity: 0.5;
pointer-events: none;
transition: opacity 200ms ease;
}
}
}
.is-medium,
.is-small,
.is-mobile {
table.wc-block-cart-items {
td {
padding: 0;
}
.wc-block-cart-items__header {
display: none;
}
.wc-block-cart-item__remove-link {
display: none;
}
&:not(.wc-block-mini-cart-items) {
.wc-block-cart-items__row {
@include with-translucent-border( 0 0 1px );
}
}
.wc-block-cart-items__row {
display: grid;
grid-template-columns: 80px 132px;
padding: $gap 0;
.wc-block-cart-item__image {
grid-column-start: 1;
grid-row-start: 1;
padding-right: $gap;
}
.wc-block-cart-item__product {
grid-column-start: 2;
grid-column-end: 4;
grid-row-start: 1;
justify-self: stretch;
padding: 0 $gap $gap 0;
}
.wc-block-cart-item__quantity {
grid-column-start: 1;
grid-row-start: 2;
vertical-align: bottom;
padding-right: $gap;
align-self: end;
padding-top: $gap;
}
.wc-block-cart-item__total {
grid-row-start: 1;
.wc-block-components-formatted-money-amount {
display: inline-block;
}
}
}
}
}
.is-large.wc-block-cart {
margin-bottom: 3em;
.wc-block-cart-items {
@include with-translucent-border( 0 0 1px );
th {
padding: 0.25rem $gap 0.25rem 0;
white-space: nowrap;
}
td {
@include with-translucent-border( 1px 0 0 );
padding: $gap 0 $gap $gap;
vertical-align: top;
}
th:last-child {
padding-right: 0;
}
td:last-child {
padding-right: $gap;
}
}
}

View File

@@ -5,7 +5,7 @@
.wc-block-components-form .wc-block-components-checkout-step {
position: relative;
border: none;
padding: 0 0 0 $gap-large;
padding: 0 0 0 $gap-larger;
background: none;
margin: 0;
@@ -21,6 +21,11 @@
.wc-block-components-checkout-step__container {
position: relative;
textarea {
font-style: inherit;
font-weight: inherit;
}
}
.wc-block-components-checkout-step__content > * {
@@ -32,14 +37,8 @@
}
.wc-block-components-checkout-step__heading {
display: flex;
justify-content: space-between;
align-content: center;
flex-wrap: wrap;
margin: em($gap-small) 0 em($gap);
position: relative;
align-items: center;
gap: em($gap);
.wc-block-components-express-payment-continue-rule + .wc-block-components-checkout-step & {
margin-top: 0;
@@ -77,7 +76,6 @@
content: "\00a0" counter(checkout-step) ".";
content: "\00a0" counter(checkout-step) "." / "";
position: absolute;
width: $gap-large;
left: -$gap-large;
top: 0;
text-align: center;

View File

@@ -35,7 +35,6 @@ const OrderSummary = ( {
{ __( 'Order summary', 'woo-gutenberg-products-block' ) }
</span>
}
titleTag="h2"
>
<div className="wc-block-components-order-summary__content">
{ cartItems.map( ( cartItem ) => {

View File

@@ -8,97 +8,97 @@
.wc-block-components-panel__content {
margin-bottom: 0;
}
}
.wc-block-components-order-summary__content {
display: table;
width: 100%;
}
.wc-block-components-order-summary-item {
@include with-translucent-border(0 0 1px);
@include font-size(small);
display: flex;
padding-bottom: 1px;
padding-top: $gap;
width: 100%;
&:first-child {
padding-top: 0;
.wc-block-components-order-summary__content {
display: table;
width: 100%;
}
&:last-child {
> div {
padding-bottom: 0;
.wc-block-components-order-summary-item {
@include with-translucent-border(0 0 1px);
@include font-size(small);
display: flex;
padding-bottom: 1px;
padding-top: $gap;
width: 100%;
&:first-child {
padding-top: 0;
}
&::after {
display: none;
&:last-child {
> div {
padding-bottom: 0;
}
&::after {
display: none;
}
}
.wc-block-components-product-metadata {
@include font-size(regular);
}
}
.wc-block-components-product-metadata {
@include font-size(regular);
.wc-block-components-order-summary-item__image,
.wc-block-components-order-summary-item__description {
display: table-cell;
vertical-align: top;
}
}
.wc-block-components-order-summary-item__image,
.wc-block-components-order-summary-item__description {
display: table-cell;
vertical-align: top;
}
.wc-block-components-order-summary-item__image {
width: #{$gap-large * 2};
padding-bottom: $gap;
position: relative;
> img {
.wc-block-components-order-summary-item__image {
width: #{$gap-large * 2};
max-width: #{$gap-large * 2};
padding-bottom: $gap;
position: relative;
> img {
width: #{$gap-large * 2};
max-width: #{$gap-large * 2};
}
}
.wc-block-components-order-summary-item__quantity {
align-items: center;
background: #fff;
border: 2px solid;
border-radius: 1em;
box-shadow: 0 0 0 2px #fff;
color: #000;
display: flex;
line-height: 1;
min-height: 20px;
padding: 0 0.4em;
position: absolute;
justify-content: center;
min-width: 20px;
right: 0;
top: 0;
transform: translate(50%, -50%);
white-space: nowrap;
z-index: 1;
}
.wc-block-components-order-summary-item__description {
padding-left: $gap-large;
padding-right: $gap-small;
padding-bottom: $gap;
p,
.wc-block-components-product-metadata {
line-height: 1.375;
margin-top: #{ ($gap-large - $gap) * 0.5 };
}
}
.wc-block-components-order-summary-item__total-price {
font-weight: bold;
margin-left: auto;
text-align: right;
}
.wc-block-components-order-summary-item__individual-prices {
display: block;
}
}
.wc-block-components-order-summary-item__quantity {
align-items: center;
background: #fff;
border: 2px solid;
border-radius: 1em;
box-shadow: 0 0 0 2px #fff;
color: #000;
display: flex;
line-height: 1;
min-height: 20px;
padding: 0 0.4em;
position: absolute;
justify-content: center;
min-width: 20px;
right: 0;
top: 0;
transform: translate(50%, -50%);
white-space: nowrap;
z-index: 1;
}
.wc-block-components-order-summary-item__description {
padding-left: $gap-large;
padding-right: $gap-small;
padding-bottom: $gap;
p,
.wc-block-components-product-metadata {
line-height: 1.375;
margin-top: #{ ($gap-large - $gap) * 0.5 };
}
}
.wc-block-components-order-summary-item__total-price {
font-weight: bold;
margin-left: auto;
text-align: right;
}
.wc-block-components-order-summary-item__individual-prices {
display: block;
}

View File

@@ -10,7 +10,7 @@ import { isPackageRateCollectable } from '@woocommerce/base-utils';
* Shows a formatted pickup location.
*/
const PickupLocation = (): JSX.Element | null => {
const { pickupAddress, pickupMethod } = useSelect( ( select ) => {
const { pickupAddress } = useSelect( ( select ) => {
const cartShippingRates = select( 'wc/store/cart' ).getShippingRates();
const flattenedRates = cartShippingRates.flatMap(
@@ -36,7 +36,6 @@ const PickupLocation = (): JSX.Element | null => {
const selectedRatePickupAddress = selectedRateMetaData.value;
return {
pickupAddress: selectedRatePickupAddress,
pickupMethod: selectedCollectableRate.name,
};
}
}
@@ -44,20 +43,15 @@ const PickupLocation = (): JSX.Element | null => {
if ( isObject( selectedCollectableRate ) ) {
return {
pickupAddress: undefined,
pickupMethod: selectedCollectableRate.name,
};
}
return {
pickupAddress: undefined,
pickupMethod: undefined,
};
} );
// If the method does not contain an address, or the method supporting collection was not found, return early.
if (
typeof pickupAddress === 'undefined' &&
typeof pickupMethod === 'undefined'
) {
if ( typeof pickupAddress === 'undefined' ) {
return null;
}
@@ -67,9 +61,7 @@ const PickupLocation = (): JSX.Element | null => {
{ sprintf(
/* translators: %s: shipping method name, e.g. "Amazon Locker" */
__( 'Collection from %s', 'woo-gutenberg-products-block' ),
typeof pickupAddress === 'undefined'
? pickupMethod
: pickupAddress
pickupAddress
) + ' ' }
</span>
);

View File

@@ -26,7 +26,7 @@ jest.mock( '@woocommerce/settings', () => {
};
} );
describe( 'PickupLocation', () => {
it( `renders an address if one is set in the method's metadata`, async () => {
it( `renders an address if one is set in the methods metadata`, async () => {
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
// Deselect the default selected rate and select pickup_location:1 rate.
@@ -54,7 +54,7 @@ describe( 'PickupLocation', () => {
)
).toBeInTheDocument();
} );
it( 'renders the method name if address is not in metadata', async () => {
it( 'renders no address if one is not set in the methods metadata', async () => {
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
// Deselect the default selected rate and select pickup_location:1 rate.
@@ -87,7 +87,7 @@ describe( 'PickupLocation', () => {
render( <PickupLocation /> );
expect(
screen.getByText( /Collection from Local pickup/ )
).toBeInTheDocument();
screen.queryByText( /Collection from / )
).not.toBeInTheDocument();
} );
} );

Some files were not shown because too many files have changed in this diff Show More