diff --git a/assets/js/atomic/blocks/index.js b/assets/js/atomic/blocks/index.js index d62fb940f32..fd7a80536df 100644 --- a/assets/js/atomic/blocks/index.js +++ b/assets/js/atomic/blocks/index.js @@ -3,6 +3,7 @@ */ import './product-elements/title'; import './product-elements/price'; +import './product-elements/price-v2'; import './product-elements/image'; import './product-elements/rating'; import './product-elements/rating-stars'; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/block.json b/assets/js/atomic/blocks/product-elements/price-v2/block.json new file mode 100644 index 00000000000..6179874ad62 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/block.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "woocommerce/product-price-v2", + "title": "Price", + "keywords": [ "WooCommerce" ], + "category": "woocommerce-product-elements", + "textdomain": "woo-gutenberg-products-block", + "description": "Display the price of a product, including any discounts.", + "supports": { + "layout": { + "allowSwitching": false, + "allowInheriting": false, + "default": { "type": "flex" } + }, + "html": false + }, + "usesContext": [ "queryId", "postId" ], + "providesContext": { + "woocommerce/isDescendentOfSingleProductTemplate": + "isDescendentOfSingleProductTemplate", + "woocommerce/isDescendentOfSingleProductBlock": + "isDescendentOfSingleProductBlock", + "woocommerce/withSuperScriptStyle": "withSuperScriptStyle" + }, + "attributes": { + "isDescendentOfSingleProductTemplate": { + "type": "boolean", + "default": false + }, + "isDescendentOfSingleProductBlock": { + "type": "boolean", + "default": false + }, + "productId": { + "type": "number", + "default": 0 + }, + "withSuperScriptStyle": { + "type": "boolean", + "default": false + } + }, + "styles": [ + { "name": "default", "label": "Default", "isDefault": true }, + { "name": "price-super", "label": "Superscript", "isDefault": false } + ] + } diff --git a/assets/js/atomic/blocks/product-elements/price-v2/constants.tsx b/assets/js/atomic/blocks/product-elements/price-v2/constants.tsx new file mode 100644 index 00000000000..a8a5261b1fe --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/constants.tsx @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import { currencyDollar, Icon } from '@wordpress/icons'; + +export const BLOCK_ICON: JSX.Element = ( + +); diff --git a/assets/js/atomic/blocks/product-elements/price-v2/edit.tsx b/assets/js/atomic/blocks/product-elements/price-v2/edit.tsx new file mode 100644 index 00000000000..af47ca58b04 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/edit.tsx @@ -0,0 +1,181 @@ +/** + * External dependencies + */ +import { useBlockProps, InnerBlocks } from '@wordpress/block-editor'; +import { useEffect, useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { + InnerBlockLayoutContextProvider, + useInnerBlockLayoutContext, + ProductDataContextProvider, +} from '@woocommerce/shared-context'; +import { useStoreProducts } from '@woocommerce/base-context/hooks'; + +/** + * Internal dependencies + */ +import { BLOCK_NAME as priceDiscountName } from './inner-blocks/discount'; +import { BLOCK_NAME as originalPriceName } from './inner-blocks/original-price'; +import { BLOCK_NAME as currentPriceName } from './inner-blocks/current-price'; +import { TEMPLATE } from './template'; +import './editor.scss'; + +interface Attributes { + isDescendentOfSingleProductBlock: boolean; + isDescendentOfSingleProductTemplate: boolean; + withSuperScriptStyle: boolean; + productId?: number; +} + +interface Context { + postId?: number; + queryId?: number; +} + +interface Props { + context: Context; + attributes: Attributes; + setAttributes: ( attributes: Partial< Attributes > ) => void; +} + +interface ContextProviderProps extends Props { + children: JSX.Element | JSX.Element[] | undefined; +} + +type ProductIdProps = Partial< ContextProviderProps > & { productId: number }; + +const deriveSuperScriptFromClass = ( className: string ): boolean => { + if ( className ) { + const classList = className.split( ' ' ); + return classList.includes( 'is-style-price-super' ); + } + return false; +}; + +const ProviderFromAPI = ( { + productId, + children, +}: ProductIdProps ): JSX.Element => { + // TODO: this would be good to derive from the WP entity store at some point. + const { products, productsLoading } = useStoreProducts( { + include: productId, + } ); + let product = null; + if ( products.length > 0 ) { + product = + products.find( + ( productIteration ) => productIteration.id === productId + ) || null; + } + + return ( + + { children } + + ); +}; + +const DerivedProductDataContextProvider = ( { + context, + attributes, + setAttributes, + children, +}: ContextProviderProps & { withSuperScript: boolean } ): JSX.Element => { + const { queryId, postId } = context; + const { productId } = attributes; + const isDescendentOfQueryLoop = Number.isFinite( queryId ); + const id = isDescendentOfQueryLoop ? postId : productId; + const isDescendentOfSingleProductTemplate = useSelect( + ( select ) => { + const editSiteStore = select( 'core/edit-site' ); + const editorPostId = editSiteStore?.getEditedPostId< + string | undefined + >(); + + return Boolean( + editorPostId?.includes( '//single-product' ) && + ! isDescendentOfQueryLoop + ); + }, + [ isDescendentOfQueryLoop ] + ); + + useEffect( + () => + setAttributes( { + isDescendentOfSingleProductTemplate, + } ), + [ isDescendentOfSingleProductTemplate, setAttributes ] + ); + if ( id && id > 0 ) { + return { children }; + } + return ( + + { children } + + ); +}; + +const EditBlock = ( { + context, + attributes, + setAttributes, + withSuperScript, +}: Props & { withSuperScript: boolean } ): JSX.Element => { + const { parentClassName } = useInnerBlockLayoutContext(); + return ( + +
+ +
+
+ ); +}; + +const Edit = ( { + setAttributes, + attributes, + ...props +}: Props ): JSX.Element => { + const blockProps = useBlockProps(); + const withSuperScript = useMemo( + () => deriveSuperScriptFromClass( blockProps.className ), + [ blockProps.className ] + ); + useEffect( () => { + setAttributes( { withSuperScriptStyle: withSuperScript } ); + }, [ withSuperScript, setAttributes ] ); + return ( +
+ + + +
+ ); +}; + +export default Edit; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/editor.scss b/assets/js/atomic/blocks/product-elements/price-v2/editor.scss new file mode 100644 index 00000000000..8ad61d24194 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/editor.scss @@ -0,0 +1,12 @@ +.wc-block-price-element { + .block-editor-inner-blocks { + .block-editor-block-list__layout { + display: flex; + + .wp-block-woocommerce-original-price, + .wp-block-woocommerce-current-price { + margin-right: $gap-smaller; + } + } + } +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/index.ts b/assets/js/atomic/blocks/product-elements/price-v2/index.ts new file mode 100644 index 00000000000..044b238dbee --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/index.ts @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import { supports } from './supports'; +import metadata from './block.json'; +import { BLOCK_ICON as icon } from './constants'; +import './inner-blocks'; + +const blockConfig = { + ...metadata, + icon: { src: icon }, + supports: { ...supports, ...metadata.supports }, + edit, + save, +}; + +registerBlockType( metadata.name, blockConfig ); diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/block.json b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/block.json new file mode 100644 index 00000000000..240792ea3c1 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/block.json @@ -0,0 +1,19 @@ +{ + "name": "woocommerce/current-price", + "version": "1.0.0", + "icon": "info", + "title": "Current Price", + "description": "Display the current price for the product", + "supports": { + "color": { + "background": true, + "text": true + } + }, + "category": "woocommerce", + "keywords": [ "WooCommerce" ], + "usesContext": ["woocommerce/withSuperScriptStyle", "woocommerce/isDescendentOfSingleProductTemplate", "postId"], + "textdomain": "woo-gutenberg-products-block", + "apiVersion": 2, + "$schema": "https://schemas.wp.org/trunk/block.json" +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/block.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/block.tsx new file mode 100644 index 00000000000..2d83b7e6a3d --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/block.tsx @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import type { HTMLAttributes } from 'react'; +import classnames from 'classnames'; +import { useStyleProps } from '@woocommerce/base-hooks'; +import { useInnerBlockLayoutContext } from '@woocommerce/shared-context'; +import ProductPrice from '@woocommerce/base-components/product-price'; + +/** + * Internal dependencies + */ +import type { PriceProps } from '../../types'; + +type Props = PriceProps & HTMLAttributes< HTMLDivElement >; + +const Block = ( { + attributes, + context, + rawPrice, + minPrice, + maxPrice, + currency, + isDescendentOfSingleProductTemplate, +}: Props ): JSX.Element | null => { + const { className } = attributes; + const { className: stylesClassName, style } = useStyleProps( attributes ); + const { parentClassName } = useInnerBlockLayoutContext(); + const wrapperClassName = classnames( + className, + { + [ `${ parentClassName }__product-price` ]: parentClassName, + }, + stylesClassName + ); + if ( ! rawPrice && ! isDescendentOfSingleProductTemplate ) { + return ; + } + + const pricePreview = '3000'; + const priceClassName = classnames( { + [ `${ parentClassName }__product-current-price__value` ]: + parentClassName, + } ); + + return ( + + ); +}; + +export default Block; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/edit.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/edit.tsx new file mode 100644 index 00000000000..9687453ada5 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/edit.tsx @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import type { HTMLAttributes, CSSProperties } from 'react'; +import { useProductDataContext } from '@woocommerce/shared-context'; +import { getCurrencyFromPriceResponse } from '@woocommerce/price-format'; + +/** + * Internal dependencies + */ +import PriceBlock from './block'; + +interface Attributes { + style: CSSProperties; +} + +type Props = { + attributes: Attributes; + // eslint-disable-next-line @typescript-eslint/naming-convention + context?: { 'woocommerce/isDescendentOfSingleProductTemplate': boolean }; +} & HTMLAttributes< HTMLDivElement >; + +const CurrentPriceEdit = ( { attributes, context }: Props ): JSX.Element => { + const blockProps = useBlockProps(); + const { product } = useProductDataContext(); + const isDescendentOfSingleProductTemplate = + context && context[ 'woocommerce/isDescendentOfSingleProductTemplate' ]; + const currentPrice = product?.prices?.price; + const currency = isDescendentOfSingleProductTemplate + ? getCurrencyFromPriceResponse() + : getCurrencyFromPriceResponse( product?.prices ); + const blockAttrs = { + attributes, + currency, + context, + rawPrice: currentPrice, + minPrice: product?.prices?.price_range?.min_amount, + maxPrice: product?.prices?.price_range?.max_amount, + priceType: 'current', + isDescendentOfSingleProductTemplate, + }; + return ( + <> +
+ +
+ + ); +}; + +export default CurrentPriceEdit; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/index.ts b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/index.ts new file mode 100644 index 00000000000..2da5e528913 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/current-price/index.ts @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import sharedConfig from '../../../shared/config'; +import metadata from './block.json'; +import { supports } from '../../supports'; + +const { ancestor, ...configuration } = sharedConfig; + +const blockConfig = { + ...configuration, + ...metadata, + edit, + supports: { + ...supports, + ...metadata.supports, + ...configuration.supports, + context: '', + }, +}; + +registerBlockType( metadata.name, blockConfig ); + +export const BLOCK_NAME = metadata.name; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/block.json b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/block.json new file mode 100644 index 00000000000..7a5acd04f42 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/block.json @@ -0,0 +1,25 @@ +{ + "name": "woocommerce/discount", + "version": "1.0.0", + "icon": "info", + "title": "Discount", + "description": "Display the discounted amount for a product.", + "category": "woocommerce", + "keywords": [ "WooCommerce" ], + "textdomain": "woo-gutenberg-products-block", + "usesContext": ["woocommerce/isDescendentOfSingleProductTemplate" ,"postId"], + "supports": { + "layout": { + "allowSwitching": false, + "allowInheriting": false, + "default": { "type": "flex" } + }, + "html": false, + "color": { + "background": true, + "text": true + } + }, + "apiVersion": 2, + "$schema": "https://schemas.wp.org/trunk/block.json" +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/edit.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/edit.tsx new file mode 100644 index 00000000000..85e7b24b807 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/edit.tsx @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { useBlockProps, InnerBlocks } from '@wordpress/block-editor'; +import { useProductDataContext } from '@woocommerce/shared-context'; +import { useStyleProps } from '@woocommerce/base-hooks'; +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { discountAmountName } from './inner-blocks'; +import { TEMPLATE } from './template'; +import './editor.scss'; + +export const Edit = ( { attributes, setAttributes, context } ): JSX.Element => { + const { style, className } = useStyleProps( attributes ); + const { product } = useProductDataContext(); + const originalPrice = product?.prices?.regular_price; + const currentPrice = product?.prices?.price; + const isDescendentOfSingleProductTemplate = + context && context[ 'woocommerce/isDescendentOfSingleProductTemplate' ]; + + const showPrice = + ( originalPrice && currentPrice !== originalPrice ) || + isDescendentOfSingleProductTemplate; + useEffect( () => { + if ( ! attributes?.style ) { + setAttributes( { style: { spacing: { blockGap: '0' } } } ); + } + }, [ attributes, setAttributes ] ); + return ( + <> + { showPrice && ( +
+ +
+ ) } + + ); +}; + +export const Save = (): JSX.Element => { + return ( +
+ +
+ ); +}; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/editor.scss b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/editor.scss new file mode 100644 index 00000000000..66bb3dd5b79 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/editor.scss @@ -0,0 +1,3 @@ +.wp-block-woocommerce-discount-amount + p { + margin: 0; +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/index.ts b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/index.ts new file mode 100644 index 00000000000..40171006b72 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/index.ts @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { Edit as edit, Save as save } from './edit'; +import sharedConfig from '../../../shared/config'; +import { supports } from '../../supports'; +import metadata from './block.json'; + +const { ancestor, ...configuration } = sharedConfig; + +const blockConfig = { + ...configuration, + ...metadata, + supports: { + ...supports, + ...metadata.supports, + }, + edit, + save, +}; + +registerBlockType( metadata.name, blockConfig ); + +export const BLOCK_NAME = metadata.name; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/block.json b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/block.json new file mode 100644 index 00000000000..28817262461 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/block.json @@ -0,0 +1,14 @@ +{ + "name": "woocommerce/discount-amount", + "version": "1.0.0", + "icon": "info", + "title": "Discount Amount", + "description": "Display the discounted amount value.", + "category": "woocommerce", + "usesContext": ["woocommerce/isDescendentOfSingleProductTemplate"], + "keywords": [ "WooCommerce" ], + "textdomain": "woo-gutenberg-products-block", + "ancestor": [ "woocommerce/discount" ], + "apiVersion": 2, + "$schema": "https://schemas.wp.org/trunk/block.json" +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/block.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/block.tsx new file mode 100644 index 00000000000..7835b21c450 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/block.tsx @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import type { HTMLAttributes } from 'react'; +import classnames from 'classnames'; +import { useStyleProps } from '@woocommerce/base-hooks'; +import { useInnerBlockLayoutContext } from '@woocommerce/shared-context'; +import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; + +/** + * Internal dependencies + */ +import { PriceProps } from '../../../../types'; + +type Props = PriceProps & HTMLAttributes< HTMLDivElement >; + +const calculateDiscountAmount = ( originalPrice, currentPrice ) => { + // todo: this assumes currentPrice is lower than originalPrice + return parseInt( originalPrice, 10 ) - parseInt( currentPrice, 10 ); +}; + +const calculateDiscountPercentage = ( originalPrice, currentPrice ) => { + const discountAmount = calculateDiscountAmount( + originalPrice, + currentPrice + ); + return Math.floor( + ( discountAmount / parseInt( originalPrice, 10 ) ) * 100 + ); +}; + +const Block = ( { + attributes, + originalPrice, + currentPrice, + currency, + isDescendentOfSingleProductTemplate, +}: Props ): JSX.Element | null => { + // todo: need to setup discountType and showDiscount as attributes/context + // from the parent block. + const { + className, + discountType = 'percentage', + showDiscount = true, + } = attributes; + const { className: stylesClassName, style } = useStyleProps( attributes ); + const { parentClassName } = useInnerBlockLayoutContext(); + if ( ! showDiscount ) { + return null; + } + const wrapperClassName = classnames( + className, + { + [ `${ parentClassName }__product-price` ]: parentClassName, + }, + stylesClassName + ); + const priceClassName = classnames( { + [ `${ parentClassName }__product-price_discount` ]: parentClassName, + } ); + // todo: lift all the preview pricing up to the parent block (across all the price inner blocks) + const oPrice = isDescendentOfSingleProductTemplate ? '5000' : originalPrice; + const cPrice = isDescendentOfSingleProductTemplate ? '3000' : currentPrice; + const DisplayedDiscount = + discountType === 'percentage' ? ( + + { calculateDiscountPercentage( oPrice, cPrice ) }% + + ) : ( + + ); + + return ( + + { DisplayedDiscount } + + ); +}; + +export default Block; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/edit.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/edit.tsx new file mode 100644 index 00000000000..7b3b576e580 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/edit.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import type { HTMLAttributes, CSSProperties } from 'react'; +import { useProductDataContext } from '@woocommerce/shared-context'; +import { getCurrencyFromPriceResponse } from '@woocommerce/price-format'; + +/** + * Internal dependencies + */ +import DiscountBlock from './block'; + +interface Attributes { + style: CSSProperties; +} + +type Props = { + attributes: Attributes; + // eslint-disable-next-line @typescript-eslint/naming-convention + context?: { 'woocommerce/isDescendentOfSingleProductTemplate': boolean }; +} & HTMLAttributes< HTMLDivElement >; + +const DiscountEdit = ( { attributes, context }: Props ): JSX.Element => { + const blockProps = useBlockProps(); + const { product } = useProductDataContext(); + const isDescendentOfSingleProductTemplate = + context && context[ 'woocommerce/isDescendentOfSingleProductTemplate' ]; + const originalPrice = product?.prices?.regular_price; + const currentPrice = product?.prices?.price; + const showPrice = + ( originalPrice && currentPrice !== originalPrice ) || + isDescendentOfSingleProductTemplate; + const currency = isDescendentOfSingleProductTemplate + ? getCurrencyFromPriceResponse() + : getCurrencyFromPriceResponse( product?.prices ); + const blockAttrs = { + attributes, + currency, + context, + originalPrice, + currentPrice, + isDescendentOfSingleProductTemplate, + }; + return ( + <> + { showPrice && ( +
+ +
+ ) } + + ); +}; + +export default DiscountEdit; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/index.ts b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/index.ts new file mode 100644 index 00000000000..be7063e7eb1 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/discount-amount/index.ts @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import sharedConfig from '../../../../../shared/config'; +import metadata from './block.json'; + +const { ancestor, ...configuration } = sharedConfig; + +const blockConfig = { + ...configuration, + ...metadata, + edit, + supports: { + ...configuration.supports, + context: '', + }, +}; + +registerBlockType( metadata.name, blockConfig ); + +export const BLOCK_NAME = metadata.name; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/index.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/index.tsx new file mode 100644 index 00000000000..9f20e96872f --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/inner-blocks/index.tsx @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import './discount-amount/index.ts'; + +export { BLOCK_NAME as discountAmountName } from './discount-amount/index'; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/template.ts b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/template.ts new file mode 100644 index 00000000000..58153b03b92 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/discount/template.ts @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { InnerBlockTemplate } from '@wordpress/blocks'; +import { _x, sprintf } from '@wordpress/i18n'; + +// Some hackery to both make translation clearer and to allow for RTL languages +// in default template layout. +const translatedContent = sprintf( + // translators: %s: discount amount. The rendered value will only allow for placeholder at beginning or end of string. + _x( + '%s off', + 'post text next to discount amount', + 'woo-gutenberg-products-block' + ), + ':placeholder:' +); + +const templateContent = translatedContent.replace( ':placeholder:', '' ); + +const RTLtemplate: InnerBlockTemplate[] = [ + [ 'core/paragraph', { content: templateContent }, [] ], + [ 'woocommerce/discount-amount', {}, [] ], +]; + +const LTRtemplate: InnerBlockTemplate[] = [ + [ 'woocommerce/discount-amount', {}, [] ], + [ 'core/paragraph', { content: templateContent }, [] ], +]; + +export const TEMPLATE: InnerBlockTemplate[] = + translatedContent.indexOf( ':placeholder:' ) === 0 + ? LTRtemplate + : RTLtemplate; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/index.ts b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/index.ts new file mode 100644 index 00000000000..2277fbc12b6 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/index.ts @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import './original-price'; +import './current-price'; +import './discount'; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/block.json b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/block.json new file mode 100644 index 00000000000..2c155fafaaa --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/block.json @@ -0,0 +1,19 @@ +{ + "name": "woocommerce/original-price", + "version": "1.0.0", + "icon": "info", + "title": "Original Price", + "description": "Display the original price for the product", + "supports": { + "color": { + "background": true, + "text": true + } + }, + "category": "woocommerce", + "keywords": [ "WooCommerce" ], + "usesContext": ["woocommerce/withSuperScriptStyle", "woocommerce/isDescendentOfSingleProductTemplate", "postId"], + "textdomain": "woo-gutenberg-products-block", + "apiVersion": 2, + "$schema": "https://schemas.wp.org/trunk/block.json" +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/block.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/block.tsx new file mode 100644 index 00000000000..f76794252d5 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/block.tsx @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import type { HTMLAttributes } from 'react'; +import classnames from 'classnames'; +import { useStyleProps } from '@woocommerce/base-hooks'; +import { useInnerBlockLayoutContext } from '@woocommerce/shared-context'; +import ProductPrice from '@woocommerce/base-components/product-price'; + +/** + * Internal dependencies + */ +import type { PriceProps } from '../../types'; + +type Props = PriceProps & HTMLAttributes< HTMLDivElement >; + +const Block = ( { + attributes, + context, + rawPrice, + priceType, + currency, + isDescendentOfSingleProductTemplate, +}: Props ): JSX.Element | null => { + const { className } = attributes; + const { className: stylesClassName, style } = useStyleProps( attributes ); + const { parentClassName } = useInnerBlockLayoutContext(); + const wrapperClassName = classnames( + className, + { + [ `${ parentClassName }__product-price` ]: parentClassName, + }, + stylesClassName + ); + if ( ! rawPrice && ! isDescendentOfSingleProductTemplate ) { + return ; + } + + const pricePreview = '5000'; + const priceClassName = classnames( { + [ `${ parentClassName }__product-${ priceType }-price__value` ]: + parentClassName, + } ); + + return ( + + ); +}; + +export default Block; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/edit.tsx b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/edit.tsx new file mode 100644 index 00000000000..6ebfbc16b92 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/edit.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import type { HTMLAttributes, CSSProperties } from 'react'; +import { useProductDataContext } from '@woocommerce/shared-context'; +import { getCurrencyFromPriceResponse } from '@woocommerce/price-format'; + +/** + * Internal dependencies + */ +import PriceBlock from './block'; + +interface Attributes { + style: CSSProperties; +} + +type Props = { + attributes: Attributes; + // eslint-disable-next-line @typescript-eslint/naming-convention + context?: { 'woocommerce/isDescendentOfSingleProductTemplate': boolean }; +} & HTMLAttributes< HTMLDivElement >; + +const OriginalPriceEdit = ( { attributes, context }: Props ): JSX.Element => { + const blockProps = useBlockProps(); + const { product } = useProductDataContext(); + const isDescendentOfSingleProductTemplate = + context && context[ 'woocommerce/isDescendentOfSingleProductTemplate' ]; + const originalPrice = product?.prices?.regular_price; + const currentPrice = product?.prices?.price; + const showPrice = + ( originalPrice && currentPrice !== originalPrice ) || + isDescendentOfSingleProductTemplate; + const currency = isDescendentOfSingleProductTemplate + ? getCurrencyFromPriceResponse() + : getCurrencyFromPriceResponse( product?.prices ); + const blockAttrs = { + attributes, + currency, + context, + rawPrice: originalPrice, + priceType: 'original', + isDescendentOfSingleProductTemplate, + }; + return ( + <> + { showPrice && ( +
+ +
+ ) } + + ); +}; + +export default OriginalPriceEdit; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/index.ts b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/index.ts new file mode 100644 index 00000000000..422736eed57 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/index.ts @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import sharedConfig from '../../../shared/config'; +import metadata from './block.json'; +import { supports } from '../../supports'; +import './style.scss'; + +const { ancestor, ...configuration } = sharedConfig; + +const blockConfig = { + ...configuration, + ...metadata, + edit, + supports: { + ...metadata.supports, + ...configuration.supports, + ...supports, + context: '', + }, +}; + +registerBlockType( metadata.name, blockConfig ); + +export const BLOCK_NAME = metadata.name; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/style.scss b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/style.scss new file mode 100644 index 00000000000..42b5ecee2df --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/inner-blocks/original-price/style.scss @@ -0,0 +1,3 @@ +.wp-block-woocommerce-original-price { + background-color: #c1d21c; +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/save.tsx b/assets/js/atomic/blocks/product-elements/price-v2/save.tsx new file mode 100644 index 00000000000..87b0ec215fb --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/save.tsx @@ -0,0 +1,8 @@ +/** + * External dependencies + */ +import { useInnerBlocksProps, useBlockProps } from '@wordpress/block-editor'; + +export default function save() { + return
; +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/style.scss b/assets/js/atomic/blocks/product-elements/price-v2/style.scss new file mode 100644 index 00000000000..9796974b866 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/style.scss @@ -0,0 +1,8 @@ +.wp-block-woocommerce-product-price-v2 { + display: flex; + + .wp-block-woocommerce-original-price, + .wp-block-woocommerce-current-price { + margin-right: $gap-smaller; + } +} diff --git a/assets/js/atomic/blocks/product-elements/price-v2/supports.ts b/assets/js/atomic/blocks/product-elements/price-v2/supports.ts new file mode 100644 index 00000000000..2f5caecfe36 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/supports.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { isFeaturePluginBuild } from '@woocommerce/block-settings'; +import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import sharedConfig from '../shared/config'; + +/** + * todo: can use __experimentalDefaultControls to influence which controls are + * displayed by default + */ + +export const supports = { + ...sharedConfig.supports, + ...( isFeaturePluginBuild() && { + color: { + text: true, + background: true, + link: false, + }, + typography: { + fontSize: true, + lineHeight: true, + __experimentalFontFamily: true, + __experimentalFontWeight: true, + __experimentalFontStyle: true, + __experimentalLetterSpacing: true, + }, + __experimentalSelector: + '.wp-block-woocommerce-product-price .wc-block-components-product-price', + } ), + ...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && { + spacing: { + margin: true, + padding: true, + blockGap: true, + }, + } ), +}; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/template.ts b/assets/js/atomic/blocks/product-elements/price-v2/template.ts new file mode 100644 index 00000000000..ef992eb9ea5 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/template.ts @@ -0,0 +1,10 @@ +/** + * External dependencies + */ +import { InnerBlockTemplate } from '@wordpress/blocks'; + +export const TEMPLATE: InnerBlockTemplate[] = [ + [ 'woocommerce/original-price', {}, [] ], + [ 'woocommerce/current-price', {}, [] ], + [ 'woocommerce/discount', {}, [] ], +]; diff --git a/assets/js/atomic/blocks/product-elements/price-v2/types.ts b/assets/js/atomic/blocks/product-elements/price-v2/types.ts new file mode 100644 index 00000000000..397f6179819 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/price-v2/types.ts @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import type { CSSProperties } from 'react'; +import type { ProductPriceProps } from '@woocommerce/base-components/product-price'; + +// old code +export interface BlockAttributes { + productId?: number; + className?: string; + textAlign?: 'left' | 'center' | 'right'; + isDescendentOfQueryLoop?: boolean; + isDescendentOfSingleProductTemplate?: boolean; +} + +export interface PriceContext { + isDescendentOfSingleProductTemplate: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'woocommerce/withSuperScriptStyle'?: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'woocommerce/isDescendentOfSingleProductTemplate'?: boolean; +} + +export interface PriceAttributes { + className?: string; + style: CSSProperties; +} + +export interface PriceProps extends ProductPriceProps { + attributes: PriceAttributes; + context?: PriceContext; + rawPrice?: string; + minPrice?: string; + maxPrice?: string; + priceType: 'original' | 'current'; + isDescendentOfSingleProductTemplate?: boolean; +} diff --git a/assets/js/base/components/formatted-monetary-amount/index.tsx b/assets/js/base/components/formatted-monetary-amount/index.tsx index f4a5e97157a..956b74f9950 100644 --- a/assets/js/base/components/formatted-monetary-amount/index.tsx +++ b/assets/js/base/components/formatted-monetary-amount/index.tsx @@ -18,16 +18,42 @@ import './style.scss'; interface FormattedMonetaryAmountProps extends Omit< NumberFormatProps, 'onValueChange' > { className?: string; - displayType?: NumberFormatProps[ 'displayType' ]; allowNegative?: boolean; isAllowed?: ( formattedValue: NumberFormatValues ) => boolean; value: number | string; // Value of money amount. currency: Currency | Record< string, never >; // Currency configuration object. onValueChange?: ( unit: number ) => void; // Function to call when value changes. style?: React.CSSProperties | undefined; - renderText?: ( value: string ) => JSX.Element; + renderText?: ( value: string ) => ReactElement | string; + withSuperScript?: boolean; } +const applySuperscript = + ( currency: FormattedMonetaryAmountProps[ 'currency' ] ) => + ( formattedValue: string ): ReactElement | string => { + if ( ! currency.decimalSeparator ) { + return formattedValue; + } + const pattern = new RegExp( + `^(.*)([${ currency.decimalSeparator }])(([^${ currency.decimalSeparator }]*))$` + ); + const matches = formattedValue.match( pattern ); + if ( ! matches ) { + return formattedValue; + } + return ( + <> + <>{ matches[ 1 ] } + + + ${ currency.decimalSeparator } + + { matches[ 3 ] } + + + ); + }; + /** * Formats currency data into the expected format for NumberFormat. */ @@ -44,12 +70,7 @@ const currencyToNumberFormat = ( }; }; -type CustomFormattedMonetaryAmountProps = Omit< - FormattedMonetaryAmountProps, - 'currency' -> & { - currency: Currency | Record< string, never >; -}; +const defaultRenderText = ( formattedAmount: string ) => formattedAmount; /** * FormattedMonetaryAmount component. @@ -62,8 +83,10 @@ const FormattedMonetaryAmount = ( { currency, onValueChange, displayType = 'text', + withSuperScript = false, + renderText, ...props -}: CustomFormattedMonetaryAmountProps ): ReactElement | null => { +}: FormattedMonetaryAmountProps ): JSX.Element | null => { const value = typeof rawValue === 'string' ? parseInt( rawValue, 10 ) : rawValue; @@ -100,6 +123,12 @@ const FormattedMonetaryAmount = ( { } : () => void 0; + if ( ! renderText ) { + renderText = withSuperScript + ? applySuperscript( currency ) + : defaultRenderText; + } + return ( ); }; diff --git a/assets/js/base/components/formatted-monetary-amount/style.scss b/assets/js/base/components/formatted-monetary-amount/style.scss index c8f02bd1bee..a7821118ea5 100644 --- a/assets/js/base/components/formatted-monetary-amount/style.scss +++ b/assets/js/base/components/formatted-monetary-amount/style.scss @@ -1,3 +1,12 @@ .wc-block-components-formatted-money-amount { white-space: nowrap; } + +.formatted-money-amount-superscript { + vertical-align: super; + font-size: .5em; +} + +.formatted-money-amount-separator { + @include visually-hidden(); +} diff --git a/assets/js/base/components/product-price/index.tsx b/assets/js/base/components/product-price/index.tsx index 10fa139b3b7..9120fc98b18 100644 --- a/assets/js/base/components/product-price/index.tsx +++ b/assets/js/base/components/product-price/index.tsx @@ -17,7 +17,7 @@ interface PriceRangeProps { /** * Currency configuration object */ - currency: Currency | Record< string, never > | undefined; + currency: Currency | Record< string, never >; /** * The maximum price for the range */ @@ -38,6 +38,11 @@ interface PriceRangeProps { * **Note:** this excludes the dash in between the elements */ priceStyle?: React.CSSProperties | undefined; + /** + * If true, the decimal separator will be excluded and the amount after it + * will be wrapped in a superscript element. + */ + withSuperScript: boolean | undefined; } const PriceRange = ( { @@ -46,6 +51,7 @@ const PriceRange = ( { minPrice, priceClassName, priceStyle = {}, + withSuperScript = false, }: PriceRangeProps ) => { return ( <> @@ -69,6 +75,7 @@ const PriceRange = ( { currency={ currency } value={ minPrice } style={ priceStyle } + withSuperScript={ withSuperScript } />  —  @@ -89,7 +97,7 @@ interface SalePriceProps { /** * Currency configuration object */ - currency: Currency | Record< string, never > | undefined; + currency: Currency | Record< string, never >; /** * CSS class to be applied to the regular price container * @@ -121,7 +129,7 @@ interface SalePriceProps { /** * The new price during the sale */ - price: number | string | undefined; + price: number | string; } const SalePrice = ( { @@ -248,6 +256,11 @@ export interface ProductPriceProps { 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' > | undefined; + /** + * If true, the decimal separator will be excluded and the amount after it + * will be wrapped in a superscript element. + */ + withSuperScript: boolean | undefined; } const ProductPrice = ( { @@ -264,6 +277,7 @@ const ProductPrice = ( { regularPriceClassName, regularPriceStyle, style, + withSuperScript = false, }: ProductPriceProps ): JSX.Element => { const wrapperClassName = classNames( className, @@ -310,6 +324,7 @@ const ProductPrice = ( { minPrice={ minPrice } priceClassName={ priceClassName } priceStyle={ priceStyle } + withSuperScript={ withSuperScript } /> ); } else if ( price ) { @@ -322,6 +337,7 @@ const ProductPrice = ( { currency={ currency } value={ price } style={ priceStyle } + withSuperScript={ withSuperScript } /> ); } diff --git a/assets/js/shared/context/product-data-context.tsx b/assets/js/shared/context/product-data-context.tsx index deb6f2d8d0e..7f6979060fe 100644 --- a/assets/js/shared/context/product-data-context.tsx +++ b/assets/js/shared/context/product-data-context.tsx @@ -70,7 +70,7 @@ export const useProductDataContext = () => useContext( ProductDataContext ); interface ProductDataContextProviderProps { product: ProductResponseItem | null; - children: JSX.Element | JSX.Element[]; + children: JSX.Element | JSX.Element[] | undefined; isLoading: boolean; } diff --git a/assets/js/types/type-defs/hooks.ts b/assets/js/types/type-defs/hooks.ts index a9ee2aa58ef..53ee0fc8e07 100644 --- a/assets/js/types/type-defs/hooks.ts +++ b/assets/js/types/type-defs/hooks.ts @@ -63,9 +63,10 @@ export interface StoreCart { } export type Query = { - catalog_visibility: 'catalog'; - per_page: number; - page: number; - orderby: string; - order: string; + catalog_visibility?: 'catalog'; + per_page?: number; + page?: number; + orderby?: string; + order?: string; + include?: string | number; }; diff --git a/src/BlockTypes/ProductPriceCurrentPrice.php b/src/BlockTypes/ProductPriceCurrentPrice.php new file mode 100644 index 00000000000..9f62e80ab30 --- /dev/null +++ b/src/BlockTypes/ProductPriceCurrentPrice.php @@ -0,0 +1,68 @@ +context['postId']; + $product = wc_get_product( $post_id ); + + if ( ! $product ) { + return null; + } + + $classname = $attributes['className'] ?? ''; + $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); + + // For variable products, show the price range. + if ( ! $product->is_type( 'variable' ) ) { + $price_display = wc_price( wc_get_price_to_display( $product ) ) . $product->get_price_suffix(); + } else { + $prices = $product->get_variation_prices( 'min' ); + $max_price = wc_price( $product->get_variation_price( 'max', true ) ) . $product->get_price_suffix(); + $min_price = wc_price( current( $prices['price'] ) ) . $product->get_price_suffix(); + $price_display = $min_price . ' - ' . $max_price; + } + + return sprintf( + '
%4$s
', + esc_attr( $classes_and_styles['classes'] ), + esc_attr( $classname ), + esc_attr( $classes_and_styles['styles'] ), + $price_display + ); + } +} diff --git a/src/BlockTypes/ProductPriceDiscount.php b/src/BlockTypes/ProductPriceDiscount.php new file mode 100644 index 00000000000..2ff3af3e3c3 --- /dev/null +++ b/src/BlockTypes/ProductPriceDiscount.php @@ -0,0 +1,91 @@ + 0 ) { + $percentage_discount = floor( ( $this->calculate_discount_value( $was_price, $is_price ) / $was_price ) * 100 ); + } else { + $percentage_discount = 0; + } + return $percentage_discount; + } + + /** + * Include and render the block. + * + * @param array $attributes Block attributes. Default empty array. + * @param string $content Block content. Default empty string. + * @param WP_Block $block Block instance. + * @return string Rendered block type output. + */ + protected function render( $attributes, $content, $block ) { + $post_id = $block->context['postId']; + $product = wc_get_product( $post_id ); + if ( ! $product || ! $product->is_on_sale() ) { + return null; + } + + $classname = $attributes['className'] ?? ''; + $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); + + if ( ! $product->is_type( 'variable' ) ) { + $percentage_discount = $this->calculate_discount_percentage( $product->get_regular_price(), wc_get_price_to_display( $product ) ); + } else { + $prices = $product->get_variation_prices( 'min' ); + $percentage_discount = $this->calculate_discount_percentage( $product->get_variation_price( 'max', true ), current( $prices['price'] ) ); + } + + return sprintf( + '
%4$s
', + esc_attr( $classes_and_styles['classes'] ), + esc_attr( $classname ), + esc_attr( $classes_and_styles['styles'] ), + $percentage_discount . '%' + ) . $content; + } +} diff --git a/src/BlockTypes/ProductPriceOriginalPrice.php b/src/BlockTypes/ProductPriceOriginalPrice.php new file mode 100644 index 00000000000..0c2d019c55d --- /dev/null +++ b/src/BlockTypes/ProductPriceOriginalPrice.php @@ -0,0 +1,65 @@ +context['postId']; + $product = wc_get_product( $post_id ); + + if ( ! $product || ! $product->is_on_sale() ) { + return null; + } + + $classname = $attributes['className'] ?? ''; + $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); + + if ( ! $product->is_type( 'variable' ) ) { + $display_price = wc_price( $product->get_regular_price() ) . $product->get_price_suffix(); + } else { + // For variable products, show the max price of the variations on sale. + $display_price = wc_price( $product->get_variation_price( 'max', true ) ) . $product->get_price_suffix(); + } + + return sprintf( + '
%4$s
', + esc_attr( $classes_and_styles['classes'] ), + esc_attr( $classname ), + esc_attr( $classes_and_styles['styles'] ), + $display_price + ); + } +} diff --git a/src/BlockTypesController.php b/src/BlockTypesController.php index 21d1a062335..c961e8ac571 100644 --- a/src/BlockTypesController.php +++ b/src/BlockTypesController.php @@ -217,6 +217,9 @@ protected function get_block_types() { 'ProductDetails', 'SingleProduct', 'StockFilter', + 'ProductPriceDiscount', + 'ProductPriceCurrentPrice', + 'ProductPriceOriginalPrice', ]; $block_types = array_merge(