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',