rebase on oct-10-2023
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
.wc-block-grid__product {
|
||||
margin: 0 0 $gap-large 0;
|
||||
|
||||
.wc-block-grid__product-onsale {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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( () =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
} );
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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 } );
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -14,7 +14,10 @@ type Props = {
|
||||
};
|
||||
|
||||
const Save = ( { attributes }: Props ): JSX.Element | null => {
|
||||
if ( attributes.isDescendentOfQueryLoop ) {
|
||||
if (
|
||||
attributes.isDescendentOfQueryLoop ||
|
||||
attributes.isDescendentOfSingleProductBlock
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export const supports = {
|
||||
spacing: {
|
||||
margin: true,
|
||||
padding: true,
|
||||
__experimentalSkipSerialization: true,
|
||||
},
|
||||
} ),
|
||||
__experimentalSelector: '.wc-block-components-product-image',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 ) => {
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.wc-block-components-product-price {
|
||||
display: block;
|
||||
|
||||
.wc-block-all-products & {
|
||||
margin-bottom: $gap-small;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
|
||||
icon,
|
||||
// @ts-expect-error `edit` can be extended to include other attributes
|
||||
edit,
|
||||
ancestor: [ 'woocommerce/single-product' ],
|
||||
},
|
||||
isAvailableOnPostEditor: false,
|
||||
} );
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"title": "Product Meta",
|
||||
"icon": "product",
|
||||
"description": "Display Product Meta",
|
||||
"description": "Display a product’s SKU, categories, tags, and more.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": true,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
textAlign: string;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
textAlign: string;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
textAlign: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
|
||||
icon,
|
||||
edit,
|
||||
save,
|
||||
ancestor: [ 'woocommerce/single-product' ],
|
||||
},
|
||||
isAvailableOnPostEditor: false,
|
||||
} );
|
||||
|
||||
@@ -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' ) }
|
||||
|
||||
@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
|
||||
'woocommerce/all-products',
|
||||
'woocommerce/single-product',
|
||||
'core/post-template',
|
||||
'woocommerce/product-template',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
} ) }
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
|
||||
'woocommerce/all-products',
|
||||
'woocommerce/single-product',
|
||||
'core/post-template',
|
||||
'woocommerce/product-template',
|
||||
'woocommerce/product-meta',
|
||||
],
|
||||
edit,
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Attributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
showProductSelector: boolean;
|
||||
isDescendantOfAllProducts: boolean;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export const blockAttributes: BlockAttributes = {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
isDescendantOfAllProducts: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default blockAttributes;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,6 +32,7 @@ const blockConfig: BlockConfiguration = {
|
||||
'woocommerce/all-products',
|
||||
'woocommerce/single-product',
|
||||
'core/post-template',
|
||||
'woocommerce/product-template',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendantOfAllProducts: boolean;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
|
||||
@@ -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;
|
||||
@@ -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 )
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 } />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user