From 874e54f4477be82acb1ff3b603d4383b563fe751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Mon, 4 Dec 2023 16:42:58 +0100 Subject: [PATCH] Revert "Remove unused Add to Cart product element (#11948)" This reverts commit b8332ddacffd7617323867a70790cd940f60003a. --- assets/js/atomic/blocks/component-init.js | 9 + assets/js/atomic/blocks/index.js | 1 + .../add-to-cart/attributes.ts | 12 + .../product-elements/add-to-cart/block.tsx | 87 ++++ .../add-to-cart/constants.tsx | 15 + .../product-elements/add-to-cart/edit.tsx | 94 ++++ .../product-elements/add-to-cart/frontend.ts | 12 + .../product-elements/add-to-cart/index.ts | 29 ++ .../add-to-cart/product-types/external.tsx | 13 + .../add-to-cart/product-types/grouped.tsx | 8 + .../add-to-cart/product-types/index.ts | 4 + .../add-to-cart/product-types/simple.tsx | 57 ++ .../product-types/variable/index.tsx | 66 +++ .../product-types/variable/types.ts | 17 + .../variation-attributes/attribute-picker.tsx | 128 +++++ .../attribute-select-control.tsx | 98 ++++ .../variable/variation-attributes/index.tsx | 40 ++ .../variable/variation-attributes/style.scss | 33 ++ .../variation-attributes/test/index.ts | 487 ++++++++++++++++++ .../variable/variation-attributes/utils.ts | 295 +++++++++++ .../add-to-cart/shared/add-to-cart-button.tsx | 181 +++++++ .../add-to-cart/shared/index.ts | 3 + .../shared/product-unavailable.tsx | 19 + .../add-to-cart/shared/quantity-input.tsx | 85 +++ .../product-elements/add-to-cart/style.scss | 49 ++ .../cart-cross-sells-products/editor.scss | 13 + .../cart-cross-sells-products/style.scss | 18 + ...ature-flags-and-experimental-interfaces.md | 1 + .../translations/translation-loading.md | 5 + src/BlockTypes/ProductAddToCart.php | 32 ++ src/BlockTypesController.php | 1 + 31 files changed, 1912 insertions(+) create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/attributes.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/block.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/constants.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/edit.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/frontend.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/index.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/external.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/grouped.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/index.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/types.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/shared/index.ts create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/shared/product-unavailable.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.tsx create mode 100644 assets/js/atomic/blocks/product-elements/add-to-cart/style.scss create mode 100644 src/BlockTypes/ProductAddToCart.php diff --git a/assets/js/atomic/blocks/component-init.js b/assets/js/atomic/blocks/component-init.js index f469971e500..6bdc5b029e0 100644 --- a/assets/js/atomic/blocks/component-init.js +++ b/assets/js/atomic/blocks/component-init.js @@ -116,3 +116,12 @@ registerBlockComponent( { ) ), } ); + +registerBlockComponent( { + blockName: 'woocommerce/product-add-to-cart', + component: lazy( () => + import( + /* webpackChunkName: "product-add-to-cart" */ './product-elements/add-to-cart/frontend' + ) + ), +} ); diff --git a/assets/js/atomic/blocks/index.js b/assets/js/atomic/blocks/index.js index 65a9ddeb4f4..d62fb940f32 100644 --- a/assets/js/atomic/blocks/index.js +++ b/assets/js/atomic/blocks/index.js @@ -13,6 +13,7 @@ import './product-elements/summary'; import './product-elements/sale-badge'; import './product-elements/sku'; import './product-elements/stock-indicator'; +import './product-elements/add-to-cart'; import './product-elements/add-to-cart-form'; import './product-elements/product-image-gallery'; import './product-elements/product-details'; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/attributes.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/attributes.ts new file mode 100644 index 00000000000..993ebcc8ec6 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/attributes.ts @@ -0,0 +1,12 @@ +export const blockAttributes = { + showFormElements: { + type: 'boolean', + default: false, + }, + productId: { + type: 'number', + default: 0, + }, +}; + +export default blockAttributes; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/block.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/block.tsx new file mode 100644 index 00000000000..19dd85dde07 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/block.tsx @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { + AddToCartFormContextProvider, + useAddToCartFormContext, +} from '@woocommerce/base-context'; +import { useProductDataContext } from '@woocommerce/shared-context'; +import { isEmpty } from '@woocommerce/types'; +import { withProductDataContext } from '@woocommerce/shared-hocs'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { AddToCartButton } from './shared'; +import { + SimpleProductForm, + VariableProductForm, + ExternalProductForm, + GroupedProductForm, +} from './product-types'; + +interface Props { + /** + * CSS Class name for the component. + */ + className?: string; + /** + * Whether or not to show form elements. + */ + showFormElements?: boolean; +} + +/** + * Renders the add to cart form using useAddToCartFormContext. + */ +const AddToCartForm = () => { + const { showFormElements, productType } = useAddToCartFormContext(); + + if ( showFormElements ) { + if ( productType === 'variable' ) { + return ; + } + if ( productType === 'grouped' ) { + return ; + } + if ( productType === 'external' ) { + return ; + } + if ( productType === 'simple' || productType === 'variation' ) { + return ; + } + return null; + } + + return ; +}; + +/** + * Product Add to Form Block Component. + */ +const Block = ( { className, showFormElements }: Props ) => { + const { product } = useProductDataContext(); + const componentClass = classnames( + className, + 'wc-block-components-product-add-to-cart', + { + 'wc-block-components-product-add-to-cart--placeholder': + isEmpty( product ), + } + ); + + return ( + +
+ +
+
+ ); +}; + +export default withProductDataContext( Block ); diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/constants.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/constants.tsx new file mode 100644 index 00000000000..d37c26dc389 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/constants.tsx @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { cart } from '@woocommerce/icons'; +import { Icon } from '@wordpress/icons'; + +export const BLOCK_TITLE = __( 'Add to Cart', 'woo-gutenberg-products-block' ); +export const BLOCK_ICON = ( + +); +export const BLOCK_DESCRIPTION = __( + 'Displays an add to cart button. Optionally displays other add to cart form elements.', + 'woo-gutenberg-products-block' +); diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/edit.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/edit.tsx new file mode 100644 index 00000000000..fc9e1c98748 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/edit.tsx @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import EditProductLink from '@woocommerce/editor-components/edit-product-link'; +import { useProductDataContext } from '@woocommerce/shared-context'; +import classnames from 'classnames'; +import { + Disabled, + PanelBody, + ToggleControl, + Notice, +} from '@wordpress/components'; +import { InspectorControls } from '@wordpress/block-editor'; +import { productSupportsAddToCartForm } from '@woocommerce/base-utils'; + +/** + * Internal dependencies + */ +import './style.scss'; +import Block from './block'; +import withProductSelector from '../shared/with-product-selector'; +import { BLOCK_TITLE, BLOCK_ICON } from './constants'; + +interface EditProps { + attributes: { + className: string; + showFormElements: boolean; + }; + setAttributes: ( attributes: { showFormElements: boolean } ) => void; +} + +const Edit = ( { attributes, setAttributes }: EditProps ) => { + const { product } = useProductDataContext(); + const { className, showFormElements } = attributes; + + return ( +
+ + + + { productSupportsAddToCartForm( product ) ? ( + + setAttributes( { + showFormElements: ! showFormElements, + } ) + } + /> + ) : ( + + { __( + 'This product does not support the block based add to cart form. A link to the product page will be shown instead.', + 'woo-gutenberg-products-block' + ) } + + ) } + + + + + +
+ ); +}; + +export default withProductSelector( { + icon: BLOCK_ICON, + label: BLOCK_TITLE, + description: __( + 'Choose a product to display its add to cart form.', + 'woo-gutenberg-products-block' + ), +} )( Edit ); diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/frontend.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/frontend.ts new file mode 100644 index 00000000000..b6c773996b2 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/frontend.ts @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +import { withFilteredAttributes } from '@woocommerce/shared-hocs'; + +/** + * Internal dependencies + */ +import Block from './block'; +import attributes from './attributes'; + +export default withFilteredAttributes( attributes )( Block ); diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/index.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/index.ts new file mode 100644 index 00000000000..beb3e451bcf --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/index.ts @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { registerExperimentalBlockType } from '@woocommerce/block-settings'; + +/** + * Internal dependencies + */ +import sharedConfig from '../shared/config'; +import edit from './edit'; +import attributes from './attributes'; +import { + BLOCK_TITLE as title, + BLOCK_ICON as icon, + BLOCK_DESCRIPTION as description, +} from './constants'; + +const blockConfig = { + title, + description, + icon: { src: icon }, + edit, + attributes, +}; + +registerExperimentalBlockType( 'woocommerce/product-add-to-cart', { + ...sharedConfig, + ...blockConfig, +} ); diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/external.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/external.tsx new file mode 100644 index 00000000000..fa2455ecb2a --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/external.tsx @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import AddToCartButton from '../shared/add-to-cart-button'; + +/** + * External Product Add To Cart Form + */ +const External = () => { + return ; +}; + +export default External; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/grouped.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/grouped.tsx new file mode 100644 index 00000000000..1c5dae4c56e --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/grouped.tsx @@ -0,0 +1,8 @@ +/** + * Grouped Product Add To Cart Form + */ +const Grouped = () => ( +

This is a placeholder for the grouped products form element.

+); + +export default Grouped; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/index.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/index.ts new file mode 100644 index 00000000000..9c307daebc3 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/index.ts @@ -0,0 +1,4 @@ +export { default as SimpleProductForm } from './simple'; +export { default as VariableProductForm } from './variable/index'; +export { default as ExternalProductForm } from './external'; +export { default as GroupedProductForm } from './grouped'; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.tsx new file mode 100644 index 00000000000..8dc707ef27d --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useAddToCartFormContext } from '@woocommerce/base-context'; + +/** + * Internal dependencies + */ +import { AddToCartButton, QuantityInput, ProductUnavailable } from '../shared'; + +/** + * Simple Product Add To Cart Form + */ +const Simple = () => { + // @todo Add types for `useAddToCartFormContext` + const { + product, + quantity, + minQuantity, + maxQuantity, + multipleOf, + dispatchActions, + isDisabled, + } = useAddToCartFormContext(); + + if ( product.id && ! product.is_purchasable ) { + return ; + } + + if ( product.id && ! product.is_in_stock ) { + return ( + + ); + } + + return ( + <> + + + + ); +}; + +export default Simple; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.tsx new file mode 100644 index 00000000000..8aeb00f67ac --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.tsx @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useAddToCartFormContext } from '@woocommerce/base-context'; + +/** + * Internal dependencies + */ +import { + AddToCartButton, + QuantityInput, + ProductUnavailable, +} from '../../shared'; +import VariationAttributes from './variation-attributes'; + +/** + * Variable Product Add To Cart Form + */ +const Variable = () => { + // @todo Add types for `useAddToCartFormContext` + const { + product, + quantity, + minQuantity, + maxQuantity, + multipleOf, + dispatchActions, + isDisabled, + } = useAddToCartFormContext(); + + if ( product.id && ! product.is_purchasable ) { + return ; + } + + if ( product.id && ! product.is_in_stock ) { + return ( + + ); + } + + return ( + <> + + + + + ); +}; + +export default Variable; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/types.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/types.ts new file mode 100644 index 00000000000..0704b3ca06d --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/types.ts @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { Dictionary } from '@woocommerce/types'; + +export type AttributesMap = Record< + string, + { id: number; attributes: Dictionary } +>; + +export interface VariationParam { + id: number; + variation: { + attribute: string; + value: string; + }[]; +} diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.tsx new file mode 100644 index 00000000000..0a0a48a629e --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.tsx @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +import { useState, useEffect, useMemo } from '@wordpress/element'; +import { useShallowEqual } from '@woocommerce/base-hooks'; +import type { SelectControl } from '@wordpress/components'; +import { Dictionary, ProductResponseAttributeItem } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import AttributeSelectControl from './attribute-select-control'; +import { + getVariationMatchingSelectedAttributes, + getActiveSelectControlOptions, + getDefaultAttributes, +} from './utils'; +import { AttributesMap, VariationParam } from '../types'; + +interface Props { + attributes: Record< string, ProductResponseAttributeItem >; + setRequestParams: ( param: VariationParam ) => void; + variationAttributes: AttributesMap; +} + +/** + * AttributePicker component. + */ +const AttributePicker = ( { + attributes, + variationAttributes, + setRequestParams, +}: Props ) => { + const currentAttributes = useShallowEqual( attributes ); + const currentVariationAttributes = useShallowEqual( variationAttributes ); + const [ variationId, setVariationId ] = useState( 0 ); + const [ selectedAttributes, setSelectedAttributes ] = + useState< Dictionary >( {} ); + const [ hasSetDefaults, setHasSetDefaults ] = useState( false ); + + // Get options for each attribute picker. + const filteredAttributeOptions = useMemo( () => { + return getActiveSelectControlOptions( + currentAttributes, + currentVariationAttributes, + selectedAttributes + ); + }, [ selectedAttributes, currentAttributes, currentVariationAttributes ] ); + + // Set default attributes as selected. + useEffect( () => { + if ( ! hasSetDefaults ) { + const defaultAttributes = getDefaultAttributes( attributes ); + if ( defaultAttributes ) { + setSelectedAttributes( { + ...defaultAttributes, + } ); + } + setHasSetDefaults( true ); + } + }, [ selectedAttributes, attributes, hasSetDefaults ] ); + + // Select variations when selections are change. + useEffect( () => { + const hasSelectedAllAttributes = + Object.values( selectedAttributes ).filter( + ( selected ) => selected !== '' + ).length === Object.keys( currentAttributes ).length; + + if ( hasSelectedAllAttributes ) { + setVariationId( + getVariationMatchingSelectedAttributes( + currentAttributes, + currentVariationAttributes, + selectedAttributes + ) + ); + } else if ( variationId > 0 ) { + // Unset variation when form is incomplete. + setVariationId( 0 ); + } + }, [ + selectedAttributes, + variationId, + currentAttributes, + currentVariationAttributes, + ] ); + + // Set requests params as variation ID and data changes. + useEffect( () => { + setRequestParams( { + id: variationId, + variation: Object.keys( selectedAttributes ).map( + ( attributeName ) => { + return { + attribute: attributeName, + value: selectedAttributes[ attributeName ], + }; + } + ), + } ); + }, [ setRequestParams, variationId, selectedAttributes ] ); + + return ( +
+ { Object.keys( currentAttributes ).map( ( attributeName ) => ( + { + setSelectedAttributes( { + ...selectedAttributes, + [ attributeName ]: selected, + } ); + } } + /> + ) ) } +
+ ); +}; + +export default AttributePicker; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.tsx new file mode 100644 index 00000000000..e562ca4bb8a --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.tsx @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; +import { SelectControl } from 'wordpress-components'; +import type { SelectControl as SelectControlType } from '@wordpress/components'; +import { useEffect } from '@wordpress/element'; +import classnames from 'classnames'; +import { ValidationInputError } from '@woocommerce/blocks-components'; +import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; +import { useDispatch, useSelect } from '@wordpress/data'; + +interface Props extends SelectControlType.Props< string > { + attributeName: string; + errorMessage?: string; +} + +// Default option for select boxes. +const selectAnOption = { + value: '', + label: __( 'Select an option', 'woo-gutenberg-products-block' ), +}; + +/** + * VariationAttributeSelect component. + */ +const AttributeSelectControl = ( { + attributeName, + options = [], + value = '', + onChange = () => void 0, + errorMessage = __( + 'Please select a value.', + 'woo-gutenberg-products-block' + ), +}: Props ) => { + const errorId = attributeName; + + const { setValidationErrors, clearValidationError } = + useDispatch( VALIDATION_STORE_KEY ); + + const { error } = useSelect( ( select ) => { + const store = select( VALIDATION_STORE_KEY ); + return { + error: store.getValidationError( errorId ) || {}, + }; + } ); + + useEffect( () => { + if ( value ) { + clearValidationError( errorId ); + } else { + setValidationErrors( { + [ errorId ]: { + message: errorMessage, + hidden: true, + }, + } ); + } + }, [ + value, + errorId, + errorMessage, + clearValidationError, + setValidationErrors, + ] ); + + // Remove validation errors when unmounted. + useEffect( + () => () => void clearValidationError( errorId ), + [ errorId, clearValidationError ] + ); + + return ( +
+ + +
+ ); +}; + +export default AttributeSelectControl; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.tsx new file mode 100644 index 00000000000..a83497dacc2 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.tsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { ProductResponseItem } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import './style.scss'; +import AttributePicker from './attribute-picker'; +import { getAttributes, getVariationAttributes } from './utils'; + +interface Props { + dispatchers: { setRequestParams: () => void }; + product: ProductResponseItem; +} + +/** + * VariationAttributes component. + */ +const VariationAttributes = ( { dispatchers, product }: Props ) => { + const attributes = getAttributes( product.attributes ); + const variationAttributes = getVariationAttributes( product.variations ); + if ( + Object.keys( attributes ).length === 0 || + Object.keys( variationAttributes ).length === 0 + ) { + return null; + } + + return ( + + ); +}; + +export default VariationAttributes; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss new file mode 100644 index 00000000000..5f58b06699c --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss @@ -0,0 +1,33 @@ +.wc-block-components-product-add-to-cart-attribute-picker { + margin: 0; + flex-basis: 100%; + + label { + display: block; + @include font-size(regular); + } + + .wc-block-components-product-add-to-cart-attribute-picker__container { + position: relative; + } + + .wc-block-components-product-add-to-cart-attribute-picker__select { + margin: 0 0 em($gap-small) 0; + + select { + min-width: 60%; + min-height: 1.75em; + } + + &.has-error { + margin-bottom: $gap-large; + + select { + border-color: $alert-red; + &:focus { + outline-color: $alert-red; + } + } + } + } +} diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.ts new file mode 100644 index 00000000000..d859338da97 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.ts @@ -0,0 +1,487 @@ +/** + * External dependencies + */ +import { ProductResponseAttributeItem } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import { + getAttributes, + getVariationAttributes, + getVariationsMatchingSelectedAttributes, + getVariationMatchingSelectedAttributes, + getActiveSelectControlOptions, + getDefaultAttributes, +} from '../utils'; + +const rawAttributeData: ProductResponseAttributeItem[] = [ + { + id: 1, + name: 'Color', + taxonomy: 'pa_color', + has_variations: true, + terms: [ + { + id: 22, + name: 'Blue', + slug: 'blue', + default: true, + }, + { + id: 23, + name: 'Green', + slug: 'green', + default: false, + }, + { + id: 24, + name: 'Red', + slug: 'red', + default: false, + }, + ], + }, + { + id: 0, + name: 'Logo', + taxonomy: 'pa_logo', + has_variations: true, + terms: [ + { + id: 0, + name: 'Yes', + slug: 'Yes', + default: true, + }, + { + id: 0, + name: 'No', + slug: 'No', + default: false, + }, + ], + }, + { + id: 0, + name: 'Non-variable attribute', + taxonomy: 'pa_non-variable-attribute', + has_variations: false, + terms: [ + { + id: 0, + name: 'Test', + slug: 'Test', + default: false, + }, + { + id: 0, + name: 'Test 2', + slug: 'Test 2', + default: false, + }, + ], + }, +]; + +const rawVariations = [ + { + id: 35, + attributes: [ + { + name: 'Color', + value: 'blue', + }, + { + name: 'Logo', + value: 'Yes', + }, + ], + }, + { + id: 28, + attributes: [ + { + name: 'Color', + value: 'red', + }, + { + name: 'Logo', + value: 'No', + }, + ], + }, + { + id: 29, + attributes: [ + { + name: 'Color', + value: 'green', + }, + { + name: 'Logo', + value: 'No', + }, + ], + }, + { + id: 30, + attributes: [ + { + name: 'Color', + value: 'blue', + }, + { + name: 'Logo', + value: 'No', + }, + ], + }, +]; + +const formattedAttributes = { + Color: { + id: 1, + name: 'Color', + taxonomy: 'pa_color', + has_variations: true, + terms: [ + { + id: 22, + name: 'Blue', + slug: 'blue', + default: true, + }, + { + id: 23, + name: 'Green', + slug: 'green', + default: false, + }, + { + id: 24, + name: 'Red', + slug: 'red', + default: false, + }, + ], + }, + Size: { + id: 2, + name: 'Size', + taxonomy: 'pa_size', + has_variations: true, + terms: [ + { + id: 25, + name: 'Large', + slug: 'large', + default: false, + }, + { + id: 26, + name: 'Medium', + slug: 'medium', + default: true, + }, + { + id: 27, + name: 'Small', + slug: 'small', + default: false, + }, + ], + }, +}; + +describe( 'Testing utils', () => { + describe( 'Testing getAttributes()', () => { + it( 'returns empty object if there are no attributes', () => { + const attributes = getAttributes( null ); + expect( attributes ).toStrictEqual( {} ); + } ); + it( 'returns list of attributes when given valid data', () => { + const attributes = getAttributes( rawAttributeData ); + expect( attributes ).toStrictEqual( { + Color: { + id: 1, + name: 'Color', + taxonomy: 'pa_color', + has_variations: true, + terms: [ + { + id: 22, + name: 'Blue', + slug: 'blue', + default: true, + }, + { + id: 23, + name: 'Green', + slug: 'green', + default: false, + }, + { + id: 24, + name: 'Red', + slug: 'red', + default: false, + }, + ], + }, + Logo: { + id: 0, + name: 'Logo', + taxonomy: 'pa_logo', + has_variations: true, + terms: [ + { + id: 0, + name: 'Yes', + slug: 'Yes', + default: true, + }, + { + id: 0, + name: 'No', + slug: 'No', + default: false, + }, + ], + }, + } ); + } ); + } ); + describe( 'Testing getVariationAttributes()', () => { + it( 'returns empty object if there are no variations', () => { + const variationAttributes = getVariationAttributes( null ); + expect( variationAttributes ).toStrictEqual( {} ); + } ); + it( 'returns list of attribute names and value pairs when given valid data', () => { + const variationAttributes = getVariationAttributes( rawVariations ); + expect( variationAttributes ).toStrictEqual( { + 'id:35': { + id: 35, + attributes: { + Color: 'blue', + Logo: 'Yes', + }, + }, + 'id:28': { + id: 28, + attributes: { + Color: 'red', + Logo: 'No', + }, + }, + 'id:29': { + id: 29, + attributes: { + Color: 'green', + Logo: 'No', + }, + }, + 'id:30': { + id: 30, + attributes: { + Color: 'blue', + Logo: 'No', + }, + }, + } ); + } ); + } ); + describe( 'Testing getVariationsMatchingSelectedAttributes()', () => { + const attributes = getAttributes( rawAttributeData ); + const variationAttributes = getVariationAttributes( rawVariations ); + + it( 'returns all variations, in the correct order, if no selections have been made yet', () => { + const selectedAttributes = {}; + const matches = getVariationsMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( [ 35, 28, 29, 30 ] ); + } ); + + it( 'returns correct subset of variations after a selection', () => { + const selectedAttributes = { + Color: 'blue', + }; + const matches = getVariationsMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( [ 35, 30 ] ); + } ); + + it( 'returns correct subset of variations after all selections', () => { + const selectedAttributes = { + Color: 'blue', + Logo: 'No', + }; + const matches = getVariationsMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( [ 30 ] ); + } ); + + it( 'returns no results if selection does not match or is invalid', () => { + const selectedAttributes = { + Color: 'brown', + }; + const matches = getVariationsMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( [] ); + } ); + } ); + describe( 'Testing getVariationMatchingSelectedAttributes()', () => { + const attributes = getAttributes( rawAttributeData ); + const variationAttributes = getVariationAttributes( rawVariations ); + + it( 'returns first match if no selections have been made yet', () => { + const selectedAttributes = {}; + const matches = getVariationMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( 35 ); + } ); + + it( 'returns first match after single selection', () => { + const selectedAttributes = { + Color: 'blue', + }; + const matches = getVariationMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( 35 ); + } ); + + it( 'returns correct match after all selections', () => { + const selectedAttributes = { + Color: 'blue', + Logo: 'No', + }; + const matches = getVariationMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( 30 ); + } ); + + it( 'returns no match if invalid', () => { + const selectedAttributes = { + Color: 'brown', + }; + const matches = getVariationMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + expect( matches ).toStrictEqual( 0 ); + } ); + } ); + describe( 'Testing getActiveSelectControlOptions()', () => { + const attributes = getAttributes( rawAttributeData ); + const variationAttributes = getVariationAttributes( rawVariations ); + + it( 'returns all possible options if no selections have been made yet', () => { + const selectedAttributes = {}; + const controlOptions = getActiveSelectControlOptions( + attributes, + variationAttributes, + selectedAttributes + ); + expect( controlOptions ).toStrictEqual( { + Color: [ + { + value: 'blue', + label: 'Blue', + }, + { + value: 'green', + label: 'Green', + }, + { + value: 'red', + label: 'Red', + }, + ], + Logo: [ + { + value: 'Yes', + label: 'Yes', + }, + { + value: 'No', + label: 'No', + }, + ], + } ); + } ); + + it( 'returns only valid options if color is selected', () => { + const selectedAttributes = { + Color: 'green', + }; + const controlOptions = getActiveSelectControlOptions( + attributes, + variationAttributes, + selectedAttributes + ); + expect( controlOptions ).toStrictEqual( { + Color: [ + { + value: 'blue', + label: 'Blue', + }, + { + value: 'green', + label: 'Green', + }, + { + value: 'red', + label: 'Red', + }, + ], + Logo: [ + { + value: 'No', + label: 'No', + }, + ], + } ); + } ); + } ); + describe( 'Testing getDefaultAttributes()', () => { + const defaultAttributes = getDefaultAttributes( formattedAttributes ); + + it( 'should return default attributes in the format that is ready for setting state', () => { + expect( defaultAttributes ).toStrictEqual( { + Color: 'blue', + Size: 'medium', + } ); + } ); + + it( 'should return an empty object if given unexpected values', () => { + // @ts-expect-error Expected TS Error as we are checking how the function does with *unexpected values*. + expect( getDefaultAttributes( [] ) ).toStrictEqual( {} ); + // @ts-expect-error Ditto above. + expect( getDefaultAttributes( null ) ).toStrictEqual( {} ); + // @ts-expect-error Ditto above. + expect( getDefaultAttributes( undefined ) ).toStrictEqual( {} ); + } ); + } ); +} ); diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.ts new file mode 100644 index 00000000000..edb77eabb73 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.ts @@ -0,0 +1,295 @@ +/** + * External dependencies + */ +import { decodeEntities } from '@wordpress/html-entities'; +import { + Dictionary, + isObject, + ProductResponseAttributeItem, + ProductResponseTermItem, + ProductResponseVariationsItem, +} from '@woocommerce/types'; +import { keyBy } from '@woocommerce/base-utils'; + +/** + * Internal dependencies + */ +import { AttributesMap } from '../types'; + +/** + * Key an array of attributes by name, + */ +export const getAttributes = ( + attributes?: ProductResponseAttributeItem[] | null +) => { + return attributes + ? keyBy( + Object.values( attributes ).filter( + ( { has_variations: hasVariations } ) => hasVariations + ), + 'name' + ) + : {}; +}; + +/** + * Format variations from the API into a map of just the attribute names and values. + * + * Note, each item is keyed by the variation ID with an id: prefix. This is to prevent the object + * being reordered when iterated. + */ +export const getVariationAttributes = ( + /** + * List of Variation objects and attributes keyed by variation ID. + */ + variations?: ProductResponseVariationsItem[] | null +) => { + if ( ! variations ) { + return {}; + } + + const attributesMap: AttributesMap = {}; + + variations.forEach( ( { id, attributes } ) => { + attributesMap[ `id:${ id }` ] = { + id, + attributes: attributes.reduce( ( acc, { name, value } ) => { + acc[ name ] = value; + return acc; + }, {} as Dictionary ), + }; + } ); + + return attributesMap; +}; + +/** + * Given a list of variations and a list of attribute values, return variations which match. + * + * Allows an attribute to be excluded by name. This is used to filter displayed options for + * individual attribute selects. + * + * @return List of matching variation IDs. + */ +export const getVariationsMatchingSelectedAttributes = ( + /** + * List of attribute names and terms. + * + * As returned from {@link getAttributes()}. + */ + attributes: Record< string, ProductResponseAttributeItem >, + /** + * Attributes for each variation keyed by variation ID. + * + * As returned from {@link getVariationAttributes()}. + */ + variationAttributes: AttributesMap, + /** + * Attribute Name Value pairs of current selections by the user. + */ + selectedAttributes: Record< string, string | null > +) => { + const variationIds = Object.values( variationAttributes ).map( + ( { id } ) => id + ); + + // If nothing is selected yet, just return all variations. + if ( + Object.values( selectedAttributes ).every( ( value ) => value === '' ) + ) { + return variationIds; + } + + const attributeNames = Object.keys( attributes ); + + return variationIds.filter( ( variationId ) => + attributeNames.every( ( attributeName ) => { + const selectedAttribute = selectedAttributes[ attributeName ] || ''; + const variationAttribute = + variationAttributes[ 'id:' + variationId ].attributes[ + attributeName + ]; + + // If there is no selected attribute, consider this a match. + if ( selectedAttribute === '' ) { + return true; + } + // If the variation attributes for this attribute are set to null, it matches all values. + if ( variationAttribute === null ) { + return true; + } + // Otherwise, only match if the selected values are the same. + return variationAttribute === selectedAttribute; + } ) + ); +}; + +/** + * Given a list of variations and a list of attribute values, returns the first matched variation ID. + * + * @return Variation ID. + */ +export const getVariationMatchingSelectedAttributes = ( + /** + * List of attribute names and terms. + * + * As returned from {@link getAttributes()}. + */ + attributes: Record< string, ProductResponseAttributeItem >, + /** + * Attributes for each variation keyed by variation ID. + * + * As returned from {@link getVariationAttributes()}. + */ + variationAttributes: AttributesMap, + /** + * Attribute Name Value pairs of current selections by the user. + */ + selectedAttributes: Dictionary +) => { + const matchingVariationIds = getVariationsMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributes + ); + return matchingVariationIds[ 0 ] || 0; +}; + +/** + * Given a list of terms, filter them and return valid options for the select boxes. + * + * @see getActiveSelectControlOptions + * + * @return Value/Label pairs of select box options. + */ +const getValidSelectControlOptions = ( + /** + * List of attribute term objects. + */ + attributeTerms: ProductResponseTermItem[], + /** + * Valid values if selections have been made already. + */ + validAttributeTerms: Array< string | null > | null = null +) => { + return Object.values( attributeTerms ) + .map( ( { name, slug } ) => { + if ( + validAttributeTerms === null || + validAttributeTerms.includes( null ) || + validAttributeTerms.includes( slug ) + ) { + return { + value: slug, + label: decodeEntities( name ), + }; + } + return null; + } ) + .filter( Boolean ); +}; + +/** + * Given a list of terms, filter them and return active options for the select boxes. This factors in + * which options should be hidden due to current selections. + * + * @return Select box options. + */ +export const getActiveSelectControlOptions = ( + /** + * List of attribute names and terms. + * + * As returned from {@link getAttributes()}. + */ + attributes: Record< string, ProductResponseAttributeItem >, + /** + * Attributes for each variation keyed by variation ID. + * + * As returned from {@link getVariationAttributes()}. + */ + variationAttributes: AttributesMap, + /** + * Attribute Name Value pairs of current selections by the user. + */ + selectedAttributes: Dictionary +) => { + const options: Record< + string, + Array< { label: string; value: string } | null > + > = {}; + const attributeNames = Object.keys( attributes ); + const hasSelectedAttributes = + Object.values( selectedAttributes ).filter( Boolean ).length > 0; + + attributeNames.forEach( ( attributeName ) => { + const currentAttribute = attributes[ attributeName ]; + const selectedAttributesExcludingCurrentAttribute = { + ...selectedAttributes, + [ attributeName ]: null, + }; + // This finds matching variations for selected attributes apart from this one. This will be + // used to get valid attribute terms of the current attribute narrowed down by those matching + // variation IDs. For example, if I had Large Blue Shirts and Medium Red Shirts, I want to only + // show Red shirts if Medium is selected. + const matchingVariationIds = hasSelectedAttributes + ? getVariationsMatchingSelectedAttributes( + attributes, + variationAttributes, + selectedAttributesExcludingCurrentAttribute + ) + : null; + // Uses the above matching variation IDs to get the attributes from just those variations. + const validAttributeTerms = + matchingVariationIds !== null + ? matchingVariationIds.map( + ( varId ) => + variationAttributes[ 'id:' + varId ].attributes[ + attributeName + ] + ) + : null; + // Intersects attributes with valid attributes. + options[ attributeName ] = getValidSelectControlOptions( + currentAttribute.terms, + validAttributeTerms + ); + } ); + + return options; +}; + +/** + * Return the default values of the given attributes in a format ready to be set in state. + * + * @return Default attributes. + */ +export const getDefaultAttributes = ( + /** + * List of attribute names and terms. + * + * As returned from {@link getAttributes()}. + */ + attributes: Record< string, ProductResponseAttributeItem > +) => { + if ( ! isObject( attributes ) ) { + return {}; + } + + const attributeNames = Object.keys( attributes ); + + if ( attributeNames.length === 0 ) { + return {}; + } + + const attributesEntries = Object.values( attributes ); + + return attributesEntries.reduce( ( acc, curr ) => { + const defaultValues = curr.terms.filter( ( term ) => term.default ); + + if ( defaultValues.length > 0 ) { + acc[ curr.name ] = defaultValues[ 0 ]?.slug; + } + + return acc; + }, {} as Dictionary ); +}; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.tsx new file mode 100644 index 00000000000..d36e7da5f2d --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.tsx @@ -0,0 +1,181 @@ +/** + * External dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import Button, { ButtonProps } from '@woocommerce/base-components/button'; +import { Icon, check } from '@wordpress/icons'; +import { useState, useEffect } from '@wordpress/element'; +import { useAddToCartFormContext } from '@woocommerce/base-context'; +import { + useStoreEvents, + useStoreAddToCart, +} from '@woocommerce/base-context/hooks'; +import { useInnerBlockLayoutContext } from '@woocommerce/shared-context'; + +type LinkProps = Pick< ButtonProps, 'className' | 'href' | 'onClick' | 'text' >; + +interface ButtonComponentProps + extends Pick< ButtonProps, 'className' | 'onClick' > { + /** + * Whether the button is disabled or not. + */ + isDisabled: boolean; + /** + * Whether processing is done. + */ + isDone: boolean; + /** + * Whether processing action is occurring. + */ + isProcessing: ButtonProps[ 'showSpinner' ]; + /** + * Quantity of said item currently in the cart. + */ + quantityInCart: number; +} + +/** + * Button component for non-purchasable products. + */ +const LinkComponent = ( { className, href, text, onClick }: LinkProps ) => { + return ( + + ); +}; + +/** + * Button for purchasable products. + */ +const ButtonComponent = ( { + className, + quantityInCart, + isProcessing, + isDisabled, + isDone, + onClick, +}: ButtonComponentProps ) => { + return ( + + ); +}; + +/** + * Add to Cart Form Button Component. + */ +const AddToCartButton = () => { + // @todo Add types for `useAddToCartFormContext` + const { + showFormElements, + productIsPurchasable, + productHasOptions, + product, + productType, + isDisabled, + isProcessing, + eventRegistration, + hasError, + dispatchActions, + } = useAddToCartFormContext(); + const { parentName } = useInnerBlockLayoutContext(); + const { dispatchStoreEvent } = useStoreEvents(); + const { cartQuantity } = useStoreAddToCart( product.id || 0 ); + const [ addedToCart, setAddedToCart ] = useState( false ); + const addToCartButtonData = product.add_to_cart || { + url: '', + text: '', + }; + + // Subscribe to emitter for after processing. + useEffect( () => { + const onSuccess = () => { + if ( ! hasError ) { + setAddedToCart( true ); + } + return true; + }; + const unsubscribeProcessing = + eventRegistration.onAddToCartAfterProcessingWithSuccess( + onSuccess, + 0 + ); + return () => { + unsubscribeProcessing(); + }; + }, [ eventRegistration, hasError ] ); + + /** + * We can show a real button if we are: + * + * a) Showing a full add to cart form. + * b) The product doesn't have options and can therefore be added directly to the cart. + * c) The product is purchasable. + * + * Otherwise we show a link instead. + */ + const showButton = + ( showFormElements || + ( ! productHasOptions && productType === 'simple' ) ) && + productIsPurchasable; + + return showButton ? ( + { + dispatchActions.submitForm( + `woocommerce/single-product/${ product?.id || 0 }` + ); + dispatchStoreEvent( 'cart-add-item', { + product, + listName: parentName, + } ); + } } + /> + ) : ( + { + dispatchStoreEvent( 'product-view-link', { + product, + listName: parentName, + } ); + } } + /> + ); +}; + +export default AddToCartButton; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/shared/index.ts b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/index.ts new file mode 100644 index 00000000000..9fe6eed8226 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/index.ts @@ -0,0 +1,3 @@ +export { default as AddToCartButton } from './add-to-cart-button'; +export { default as QuantityInput } from './quantity-input'; +export { default as ProductUnavailable } from './product-unavailable'; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/shared/product-unavailable.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/product-unavailable.tsx new file mode 100644 index 00000000000..77644f5bcf6 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/product-unavailable.tsx @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +const ProductUnavailable = ( { + reason = __( + 'Sorry, this product cannot be purchased.', + 'woo-gutenberg-products-block' + ), +} ) => { + return ( +
+ { reason } +
+ ); +}; + +export default ProductUnavailable; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.tsx b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.tsx new file mode 100644 index 00000000000..a9c1e257ec4 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.tsx @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import { useDebouncedCallback } from 'use-debounce'; + +type JSXInputProps = JSX.IntrinsicElements[ 'input' ]; + +interface QuantityInputProps extends Omit< JSXInputProps, 'onChange' > { + max: number; + min: number; + onChange: ( val: number | string ) => void; + step: number; +} + +/** + * Quantity Input Component. + */ +const QuantityInput = ( { + disabled, + min, + max, + step = 1, + value, + onChange, +}: QuantityInputProps ) => { + const hasMaximum = typeof max !== 'undefined'; + + /** + * The goal of this function is to normalize what was inserted, + * but after the customer has stopped typing. + * + * It's important to wait before normalizing or we end up with + * a frustrating experience, for example, if the minimum is 2 and + * the customer is trying to type "10", premature normalizing would + * always kick in at "1" and turn that into 2. + * + * Copied from + */ + const normalizeQuantity = useDebouncedCallback< ( val: number ) => void >( + ( initialValue ) => { + // We copy the starting value. + let newValue = initialValue; + + // We check if we have a maximum value, and select the lowest between what was inserted and the maximum. + if ( hasMaximum ) { + newValue = Math.min( + newValue, + // the maximum possible value in step increments. + Math.floor( max / step ) * step + ); + } + + // Select the biggest between what's inserted, the the minimum value in steps. + newValue = Math.max( newValue, Math.ceil( min / step ) * step ); + + // We round off the value to our steps. + newValue = Math.floor( newValue / step ) * step; + + // Only commit if the value has changed + if ( newValue !== initialValue ) { + onChange?.( newValue ); + } + }, + 300 + ); + + return ( + { + onChange?.( e.target.value ); + normalizeQuantity( Number( e.target.value ) ); + } } + /> + ); +}; + +export default QuantityInput; diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/style.scss b/assets/js/atomic/blocks/product-elements/add-to-cart/style.scss new file mode 100644 index 00000000000..686d216aff6 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/style.scss @@ -0,0 +1,49 @@ +.wc-block-components-product-add-to-cart { + margin: 0; + display: flex; + flex-wrap: wrap; + + .wc-block-components-product-add-to-cart-button { + margin: 0 0 em($gap-small) 0; + + .wc-block-components-button__text { + display: block; + + > svg { + fill: currentColor; + vertical-align: top; + width: 1.5em; + height: 1.5em; + margin: -0.25em 0 -0.25em 0.5em; + } + } + } + + .wc-block-components-product-add-to-cart-quantity { + margin: 0 1em em($gap-small) 0; + flex-basis: 5em; + padding: 0.618em; + background: $white; + border: 1px solid #ccc; + border-radius: $universal-border-radius; + color: #43454b; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.125); + text-align: center; + } +} + +.is-loading .wc-block-components-product-add-to-cart, +.wc-block-components-product-add-to-cart--placeholder { + .wc-block-components-product-add-to-cart-quantity, + .wc-block-components-product-add-to-cart-button { + @include placeholder(); + } +} + +.wc-block-grid .wc-block-components-product-add-to-cart { + justify-content: center; +} + +.wc-block-components-product-add-to-cart-notice { + margin: 0; +} diff --git a/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/editor.scss b/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/editor.scss index bbd36223c67..77083516b47 100644 --- a/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/editor.scss +++ b/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/editor.scss @@ -21,5 +21,18 @@ display: block; } } + + .wc-block-components-product-add-to-cart-button:not(.is-link) { + background-color: #eee; + color: #333; + margin-top: 1em; + + &:focus, + &:hover { + background-color: #d5d5d5; + border-color: #d5d5d5; + color: #333; + } + } } } diff --git a/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/style.scss b/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/style.scss index 52f626c5104..4bf8332ddda 100644 --- a/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/style.scss +++ b/assets/js/blocks/cart/inner-blocks/cart-cross-sells-products/style.scss @@ -38,6 +38,24 @@ .wc-block-components-product-button__button { margin-top: 1em; } + + .wc-block-components-product-add-to-cart { + justify-content: center; + + .wc-block-components-product-add-to-cart-button:not(.is-link) { + background-color: #eee; + color: #333; + font-weight: 600; + margin-top: 1em; + + &:focus, + &:hover { + background-color: #d5d5d5; + border-color: #d5d5d5; + color: #333; + } + } + } } } } diff --git a/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md b/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md index 123212be351..ee2a32211a1 100644 --- a/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md +++ b/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md @@ -39,6 +39,7 @@ The majority of our feature flagging is blocks, this is a list of them: - Product Gallery Next/Previous Buttons ([PHP flag](https://github.com/woocommerce/woocommerce-blocks/blob/7f0d55d54885f436778f04a6389e92b8785d5c68/src/BlockTypesController.php#L236) | [webpack flag](https://github.com/woocommerce/woocommerce-blocks/blob/7f0d55d54885f436778f04a6389e92b8785d5c68/bin/webpack-entries.js#L60-L63)). - Product Gallery Pager ([PHP flag](https://github.com/woocommerce/woocommerce-blocks/blob/7f0d55d54885f436778f04a6389e92b8785d5c68/src/BlockTypesController.php#L237) | [webpack flag](https://github.com/woocommerce/woocommerce-blocks/blob/7f0d55d54885f436778f04a6389e92b8785d5c68/bin/webpack-entries.js#L64-L67)). - Product Gallery Thumbnails ([PHP flag](https://github.com/woocommerce/woocommerce-blocks/blob/7f0d55d54885f436778f04a6389e92b8785d5c68/src/BlockTypesController.php#L238) | [webpack flag](https://github.com/woocommerce/woocommerce-blocks/blob/7f0d55d54885f436778f04a6389e92b8785d5c68/bin/webpack-entries.js#L68-L71)). +- ⚛️ Add to cart ([JS flag](https://github.com/woocommerce/woocommerce-blocks/blob/dfd2902bd8a247b5d048577db6753c5e901fc60f/assets/js/atomic/blocks/product-elements/add-to-cart/index.ts#L26-L29)). ## Features behind flags diff --git a/docs/internal-developers/translations/translation-loading.md b/docs/internal-developers/translations/translation-loading.md index f0ae7edf74b..0abe8fb3905 100644 --- a/docs/internal-developers/translations/translation-loading.md +++ b/docs/internal-developers/translations/translation-loading.md @@ -31,6 +31,11 @@ The POT file is human-readable and named `woo-gutenberg-products-block.pot`. It "X-Poedit-SearchPathExcluded-1: vendor\n" "X-Poedit-SearchPathExcluded-2: node_modules\n" +#: assets/js/atomic/blocks/product-elements/add-to-cart/constants.js:8 +msgid "Add to Cart" +msgstr "" + +#: assets/js/atomic/blocks/product-elements/add-to-cart/edit.js:39 #: assets/js/blocks/handpicked-products/block.js:42 #: assets/js/blocks/product-best-sellers/block.js:34 #: assets/js/blocks/product-category/block.js:157 diff --git a/src/BlockTypes/ProductAddToCart.php b/src/BlockTypes/ProductAddToCart.php new file mode 100644 index 00000000000..4be0031c989 --- /dev/null +++ b/src/BlockTypes/ProductAddToCart.php @@ -0,0 +1,32 @@ +register_chunk_translations( [ $this->block_name ] ); + } +} diff --git a/src/BlockTypesController.php b/src/BlockTypesController.php index f4a63ceb4fe..7dfeb72e2a3 100644 --- a/src/BlockTypesController.php +++ b/src/BlockTypesController.php @@ -233,6 +233,7 @@ protected function get_block_types() { 'MiniCart', 'StoreNotices', 'PriceFilter', + 'ProductAddToCart', 'ProductBestSellers', 'ProductButton', 'ProductCategories',