initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
export const blockName = 'woocommerce/cart-i2';
export const blockAttributes = {
isPreview: {
type: 'boolean',
default: false,
save: false,
},
isShippingCalculatorEnabled: {
type: 'boolean',
default: getSetting( 'isShippingCalculatorEnabled', true ),
},
checkoutPageId: {
type: 'number',
default: 0,
},
hasDarkControls: {
type: 'boolean',
default: getSetting( 'hasDarkEditorStyleSupport', false ),
},
showRateAfterTaxName: {
type: 'boolean',
default: true,
},
};

View File

@ -0,0 +1,103 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { dispatch } from '@wordpress/data';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { ValidationContextProvider } from '@woocommerce/base-context';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import { translateJQueryEventToNative } from '@woocommerce/base-utils';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
import {
StoreNoticesProvider,
StoreSnackbarNoticesProvider,
CartProvider,
} from '@woocommerce/base-context/providers';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
const reloadPage = () => void window.location.reload( true );
const Cart = ( { children } ) => {
const { cartIsLoading } = useStoreCart();
return (
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
<ValidationContextProvider>{ children }</ValidationContextProvider>
</LoadingMask>
);
};
const ScrollOnError = ( { scrollToTop } ) => {
useEffect( () => {
const invalidateCartData = () => {
dispatch( storeKey ).invalidateResolutionForStore();
scrollToTop();
};
// Make it so we can read jQuery events triggered by WC Core elements.
const removeJQueryAddedToCartEvent = translateJQueryEventToNative(
'added_to_cart',
'wc-blocks_added_to_cart'
);
const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative(
'removed_from_cart',
'wc-blocks_removed_from_cart'
);
document.body.addEventListener(
'wc-blocks_added_to_cart',
invalidateCartData
);
document.body.addEventListener(
'wc-blocks_removed_from_cart',
invalidateCartData
);
return () => {
removeJQueryAddedToCartEvent();
removeJQueryRemovedFromCartEvent();
document.body.removeEventListener(
'wc-blocks_added_to_cart',
invalidateCartData
);
document.body.removeEventListener(
'wc-blocks_removed_from_cart',
invalidateCartData
);
};
}, [ scrollToTop ] );
return null;
};
const Block = ( { attributes, children, scrollToTop } ) => (
<BlockErrorBoundary
header={ __( 'Something went wrong…', 'woocommerce' ) }
text={ __(
'The cart has encountered an unexpected error. If the error persists, please get in touch with us for help.',
'woocommerce'
) }
button={
<button className="wc-block-button" onClick={ reloadPage }>
{ __( 'Reload the page', 'woocommerce' ) }
</button>
}
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<StoreSnackbarNoticesProvider context="wc/cart">
<StoreNoticesProvider context="wc/cart">
<SlotFillProvider>
<CartProvider>
<Cart attributes={ attributes }>{ children }</Cart>
<ScrollOnError scrollToTop={ scrollToTop } />
</CartProvider>
</SlotFillProvider>
</StoreNoticesProvider>
</StoreSnackbarNoticesProvider>
</BlockErrorBoundary>
);
export default withScrollToTop( Block );

View File

@ -0,0 +1,101 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
import { PaymentMethodIcons } from '@woocommerce/base-components/cart-checkout';
import Button from '@woocommerce/base-components/button';
import { CHECKOUT_URL } from '@woocommerce/block-settings';
import { useCheckoutContext } from '@woocommerce/base-context';
import { usePaymentMethods } from '@woocommerce/base-context/hooks';
import { usePositionRelativeToViewport } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import './style.scss';
import type PaymentMethodConfig from '../../../../blocks-registry/payment-methods/payment-method-config';
const getIconsFromPaymentMethods = (
paymentMethods: PaymentMethodConfig[]
) => {
return Object.values( paymentMethods ).reduce( ( acc, paymentMethod ) => {
if ( paymentMethod.icons !== null ) {
acc = acc.concat( paymentMethod.icons );
}
return acc;
}, [] );
};
/**
* Checkout button rendered in the full cart page.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.link What the button is linked to.
*/
const CheckoutButton = ( { link }: { link: string } ): JSX.Element => {
const { isCalculating } = useCheckoutContext();
const [
positionReferenceElement,
positionRelativeToViewport,
] = usePositionRelativeToViewport();
const [ showSpinner, setShowSpinner ] = useState( false );
const { paymentMethods } = usePaymentMethods();
useEffect( () => {
// Add a listener to remove the spinner on the checkout button, so the saved page snapshot does not
// contain the spinner class. See https://archive.is/lOEW0 for why this is needed for Safari.
if (
typeof global.addEventListener !== 'function' ||
typeof global.removeEventListener !== 'function'
) {
return;
}
const hideSpinner = () => {
setShowSpinner( false );
};
global.addEventListener( 'pageshow', hideSpinner );
return () => {
global.removeEventListener( 'pageshow', hideSpinner );
};
}, [] );
const submitContainerContents = (
<>
<Button
className="wc-block-cart__submit-button"
href={ link || CHECKOUT_URL }
disabled={ isCalculating }
onClick={ () => setShowSpinner( true ) }
showSpinner={ showSpinner }
>
{ __( 'Proceed to Checkout', 'woo-gutenberg-products-block' ) }
</Button>
<PaymentMethodIcons
icons={ getIconsFromPaymentMethods( paymentMethods ) }
/>
</>
);
return (
<div className="wc-block-cart__submit">
{ positionReferenceElement }
{ /* The non-sticky container must always be visible because it gives height to its parent, which is required to calculate when it becomes visible in the viewport. */ }
<div className="wc-block-cart__submit-container">
{ submitContainerContents }
</div>
{ /* If the positionReferenceElement is below the viewport, display the sticky container. */ }
{ positionRelativeToViewport === 'below' && (
<div className="wc-block-cart__submit-container wc-block-cart__submit-container--sticky">
{ submitContainerContents }
</div>
) }
</div>
);
};
export default CheckoutButton;

View File

@ -0,0 +1,56 @@
.wc-block-cart__submit {
position: relative;
}
.wc-block-cart__submit-container {
padding-bottom: $gap;
}
.wc-block-cart__submit-button {
width: 100%;
margin: 0 0 $gap;
&:last-child {
margin-bottom: 0;
}
}
.is-mobile,
.is-small,
.is-medium {
.wc-block-cart__submit-container:not(.wc-block-cart__submit-container--sticky) {
padding-left: 0;
padding-right: 0;
padding-top: 0;
}
}
@include breakpoint(">782px") {
.wc-block-cart__submit-container--sticky {
display: none;
}
}
@include breakpoint("<782px") {
.wc-block-cart__submit-container--sticky {
background: $white;
bottom: 0;
left: 0;
padding: $gap;
position: fixed;
width: 100%;
z-index: 9999;
&::before {
box-shadow: 0 -10px 20px 10px currentColor;
color: transparentize($gray-400, 0.5);
content: "";
height: 100%;
left: 0;
position: absolute;
right: 0;
top: 0;
}
}
}

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
export const Columns = ( {
children,
...props
}: {
children?: React.ReactNode;
} ): JSX.Element => {
const blockProps = useBlockProps( props );
return <div { ...blockProps }>{ children }</div>;
};

View File

@ -0,0 +1 @@
export * from './columns-block';

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Context consumed by inner blocks.
*/
export type CartBlockContextProps = {
currentView: string;
};
export const CartBlockContext = createContext< CartBlockContextProps >( {
currentView: '',
} );
export const useCartBlockContext = (): CartBlockContextProps => {
return useContext( CartBlockContext );
};

View File

@ -0,0 +1,255 @@
/* tslint:disable */
/**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { CartCheckoutFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt';
import {
useBlockProps,
InnerBlocks,
InspectorControls,
BlockControls,
} from '@wordpress/block-editor';
import { PanelBody, ToggleControl, Notice } from '@wordpress/components';
import { CartCheckoutCompatibilityNotice } from '@woocommerce/editor-components/compatibility-notices';
import { CART_PAGE_ID } from '@woocommerce/block-settings';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import {
EditorProvider,
useEditorContext,
CartProvider,
} from '@woocommerce/base-context';
import { createInterpolateElement } from '@wordpress/element';
import { getAdminLink, getSetting } from '@woocommerce/settings';
import { previewCart } from '@woocommerce/resource-previews';
import { Icon, filledCart, removeCart } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import './editor.scss';
import { addClassToBody, useBlockPropsWithLocking } from './hacks';
import { useViewSwitcher } from './use-view-switcher';
import type { Attributes } from './types';
import { CartBlockContext } from './context';
// This is adds a class to body to signal if the selected block is locked
addClassToBody();
// Array of allowed block names.
const ALLOWED_BLOCKS: string[] = [
'woocommerce/filled-cart-block',
'woocommerce/empty-cart-block',
];
const BlockSettings = ( {
attributes,
setAttributes,
}: {
attributes: Attributes;
setAttributes: ( attributes: Record< string, unknown > ) => undefined;
} ): JSX.Element => {
const { isShippingCalculatorEnabled, showRateAfterTaxName } = attributes;
const { currentPostId } = useEditorContext();
return (
<InspectorControls>
{ currentPostId !== CART_PAGE_ID && (
<Notice
className="wc-block-cart__page-notice"
isDismissible={ false }
status="warning"
>
{ createInterpolateElement(
__(
'If you would like to use this block as your default cart you must update your <a>page settings in WooCommerce</a>.',
'woo-gutenberg-products-block'
),
{
a: (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={ getAdminLink(
'admin.php?page=wc-settings&tab=advanced'
) }
target="_blank"
rel="noopener noreferrer"
/>
),
}
) }
</Notice>
) }
{ getSetting( 'shippingEnabled', true ) && (
<PanelBody
title={ __(
'Shipping rates',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Shipping calculator',
'woo-gutenberg-products-block'
) }
help={ __(
'Allow customers to estimate shipping by entering their address.',
'woo-gutenberg-products-block'
) }
checked={ isShippingCalculatorEnabled }
onChange={ () =>
setAttributes( {
isShippingCalculatorEnabled: ! isShippingCalculatorEnabled,
} )
}
/>
</PanelBody>
) }
{ getSetting( 'taxesEnabled' ) &&
getSetting( 'displayItemizedTaxes', false ) &&
! getSetting( 'displayCartPricesIncludingTax', false ) && (
<PanelBody
title={ __( 'Taxes', 'woo-gutenberg-products-block' ) }
>
<ToggleControl
label={ __(
'Show rate after tax name',
'woo-gutenberg-products-block'
) }
help={ __(
'Show the percentage rate alongside each tax line in the summary.',
'woo-gutenberg-products-block'
) }
checked={ showRateAfterTaxName }
onChange={ () =>
setAttributes( {
showRateAfterTaxName: ! showRateAfterTaxName,
} )
}
/>
</PanelBody>
) }
<CartCheckoutFeedbackPrompt />
</InspectorControls>
);
};
/**
* Component to handle edit mode of "Cart Block".
*/
export const Edit = ( {
className,
attributes,
setAttributes,
clientId,
}: {
className: string;
attributes: Attributes;
setAttributes: ( attributes: Record< string, unknown > ) => undefined;
clientId: string;
} ): JSX.Element => {
const { currentView, component: ViewSwitcherComponent } = useViewSwitcher(
clientId,
[
{
view: 'woocommerce/filled-cart-block',
label: __( 'Filled Cart', 'woo-gutenberg-products-block' ),
icon: <Icon srcElement={ filledCart } />,
},
{
view: 'woocommerce/empty-cart-block',
label: __( 'Empty Cart', 'woo-gutenberg-products-block' ),
icon: <Icon srcElement={ removeCart } />,
},
]
);
const cartClassName = classnames( {
'has-dark-controls': attributes.hasDarkControls,
} );
const defaultInnerBlocksTemplate = [
[
'woocommerce/filled-cart-block',
{},
[
[
'woocommerce/cart-items-block',
{},
[ [ 'woocommerce/cart-line-items-block', {}, [] ] ],
],
[
'woocommerce/cart-totals-block',
{},
[
[ 'woocommerce/cart-order-summary-block', {}, [] ],
[ 'woocommerce/cart-express-payment-block', {}, [] ],
[ 'woocommerce/proceed-to-checkout-block', {}, [] ],
],
],
],
],
[ 'woocommerce/empty-cart-block', {}, [] ],
];
const blockProps = useBlockPropsWithLocking( {
className: classnames( className, 'wp-block-woocommerce-cart', {
'is-editor-preview': attributes.isPreview,
} ),
} );
return (
<div { ...blockProps }>
<BlockErrorBoundary
header={ __(
'Cart Block Error',
'woo-gutenberg-products-block'
) }
text={ __(
'There was an error whilst rendering the cart block. If this problem continues, try re-creating the block.',
'woo-gutenberg-products-block'
) }
showErrorMessage={ true }
errorMessagePrefix={ __(
'Error message:',
'woo-gutenberg-products-block'
) }
>
<EditorProvider previewData={ { previewCart } }>
<BlockSettings
attributes={ attributes }
setAttributes={ setAttributes }
/>
<BlockControls __experimentalShareWithChildBlocks>
<ViewSwitcherComponent />
</BlockControls>
<CartBlockContext.Provider
value={ {
currentView,
} }
>
<CartProvider>
<div className={ cartClassName }>
<InnerBlocks
allowedBlocks={ ALLOWED_BLOCKS }
template={ defaultInnerBlocksTemplate }
templateLock="insert"
/>
</div>
</CartProvider>
</CartBlockContext.Provider>
</EditorProvider>
</BlockErrorBoundary>
<CartCheckoutCompatibilityNotice blockName="cart" />
</div>
);
};
export const Save = (): JSX.Element => {
return (
<div
{ ...useBlockProps.save( {
className: 'wc-block-cart is-loading',
} ) }
>
<InnerBlocks.Content />
</div>
);
};

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { getBlockTypes } from '@wordpress/blocks';
// List of core block types to allow in inner block areas.
const coreBlockTypes = [ 'core/paragraph', 'core/image', 'core/separator' ];
/**
* Gets a list of allowed blocks types under a specific parent block type.
*/
export const getAllowedBlocks = ( block: string ): string[] => [
...getBlockTypes()
.filter( ( blockType ) =>
( blockType?.parent || [] ).includes( block )
)
.map( ( { name } ) => name ),
...coreBlockTypes,
];

View File

@ -0,0 +1,22 @@
.wc-block-cart__page-notice {
margin: 0;
}
body.wc-lock-selected-block--move {
.block-editor-block-mover__move-button-container,
.block-editor-block-mover {
display: none;
}
}
body.wc-lock-selected-block--remove {
.block-editor-block-settings-menu__popover {
.components-menu-group:last-child {
display: none;
}
.components-menu-group:nth-last-child(2) {
margin-bottom: -12px;
}
}
}

View File

@ -0,0 +1 @@
export default 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzgiIGhlaWdodD0iMzgiIHZpZXdCb3g9IjAgMCAzOCAzOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE5IDBDOC41MDQwMyAwIDAgOC41MDQwMyAwIDE5QzAgMjkuNDk2IDguNTA0MDMgMzggMTkgMzhDMjkuNDk2IDM4IDM4IDI5LjQ5NiAzOCAxOUMzOCA4LjUwNDAzIDI5LjQ5NiAwIDE5IDBaTTI1LjEyOSAxMi44NzFDMjYuNDg1MSAxMi44NzEgMjcuNTgwNiAxMy45NjY1IDI3LjU4MDYgMTUuMzIyNkMyNy41ODA2IDE2LjY3ODYgMjYuNDg1MSAxNy43NzQyIDI1LjEyOSAxNy43NzQyQzIzLjc3MyAxNy43NzQyIDIyLjY3NzQgMTYuNjc4NiAyMi42Nzc0IDE1LjMyMjZDMjIuNjc3NCAxMy45NjY1IDIzLjc3MyAxMi44NzEgMjUuMTI5IDEyLjg3MVpNMTEuNjQ1MiAzMS4yNTgxQzkuNjE0OTIgMzEuMjU4MSA3Ljk2Nzc0IDI5LjY0OTIgNy45Njc3NCAyNy42NTczQzcuOTY3NzQgMjYuMTI1IDEwLjE1MTIgMjMuMDI5OCAxMS4xNTQ4IDIxLjY5NjhDMTEuNCAyMS4zNjczIDExLjg5MDMgMjEuMzY3MyAxMi4xMzU1IDIxLjY5NjhDMTMuMTM5MSAyMy4wMjk4IDE1LjMyMjYgMjYuMTI1IDE1LjMyMjYgMjcuNjU3M0MxNS4zMjI2IDI5LjY0OTIgMTMuNjc1NCAzMS4yNTgxIDExLjY0NTIgMzEuMjU4MVpNMTIuODcxIDE3Ljc3NDJDMTEuNTE0OSAxNy43NzQyIDEwLjQxOTQgMTYuNjc4NiAxMC40MTk0IDE1LjMyMjZDMTAuNDE5NCAxMy45NjY1IDExLjUxNDkgMTIuODcxIDEyLjg3MSAxMi44NzFDMTQuMjI3IDEyLjg3MSAxNS4zMjI2IDEzLjk2NjUgMTUuMzIyNiAxNS4zMjI2QzE1LjMyMjYgMTYuNjc4NiAxNC4yMjcgMTcuNzc0MiAxMi44NzEgMTcuNzc0MlpNMjUuOTEwNSAyOS41ODc5QzI0LjE5NDQgMjcuNTM0NyAyMS42NzM4IDI2LjM1NDggMTkgMjYuMzU0OEMxNy4zNzU4IDI2LjM1NDggMTcuMzc1OCAyMy45MDMyIDE5IDIzLjkwMzJDMjIuNDAxNiAyMy45MDMyIDI1LjYxMTcgMjUuNDA0OCAyNy43ODc1IDI4LjAyNUMyOC44NDQ4IDI5LjI4MTUgMjYuOTI5NCAzMC44MjE0IDI1LjkxMDUgMjkuNTg3OVoiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo=';

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/block-editor';
import { SHOP_URL } from '@woocommerce/block-settings';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import iconDataUri from './icon-data-uri.js';
import './style.scss';
const templateItemBrowseStore = SHOP_URL
? [
'core/paragraph',
{
align: 'center',
content: sprintf(
/* translators: %s is the link to the store product directory. */
__(
'<a href="%s">Browse store</a>.',
'woocommerce'
),
SHOP_URL
),
dropCap: false,
},
]
: null;
const templateItems = [
[
'core/image',
{
align: 'center',
url: iconDataUri,
sizeSlug: 'small',
},
],
[
'core/heading',
{
textAlign: 'center',
content: __(
'Your cart is currently empty!',
'woocommerce'
),
level: 2,
className: 'wc-block-cart__empty-cart__title',
},
],
templateItemBrowseStore,
[
'core/separator',
{
className: 'is-style-dots',
},
],
[
'core/heading',
{
textAlign: 'center',
content: __( 'New in store', 'woocommerce' ),
level: 2,
},
],
[
'woocommerce/product-new',
{
columns: 3,
rows: 1,
},
],
].filter( Boolean );
/**
* Component to handle edit mode for the Cart block when cart is empty.
*
* @param {Object} props Incoming props for the component.
* @param {boolean} props.hidden Whether this component is hidden or not.
*/
const EmptyCartEdit = ( { hidden = false } ) => {
return (
<div hidden={ hidden }>
<InnerBlocks
templateInsertUpdatesSelection={ false }
template={ templateItems }
/>
</div>
);
};
EmptyCartEdit.propTypes = {
hidden: PropTypes.bool,
};
export default EmptyCartEdit;

View File

@ -0,0 +1,4 @@
.wc-block-cart__empty-cart__title,
.editor-styles-wrapper .wc-block-cart__empty-cart__title {
font-size: inherit;
}

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import {
withStoreCartApiHydration,
withRestApiHydration,
} from '@woocommerce/block-hocs';
import { getValidBlockAttributes } from '@woocommerce/base-utils';
import { Children, cloneElement, isValidElement } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context';
import { useValidation } from '@woocommerce/base-context/hooks';
import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry';
import { renderParentBlock } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import './inner-blocks/register-components';
import Block from './block';
import { blockName, blockAttributes } from './attributes';
const getProps = ( el ) => {
return {
attributes: getValidBlockAttributes(
blockAttributes,
!! el ? el.dataset : {}
),
};
};
const Wrapper = ( { children } ) => {
// we need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { extensions, receiveCart, ...cart } = useStoreCart();
const validation = useValidation();
return Children.map( children, ( child ) => {
if ( isValidElement( child ) ) {
const componentProps = {
extensions,
cart,
validation,
};
return cloneElement( child, componentProps );
}
return child;
} );
};
renderParentBlock( {
Block: withStoreCartApiHydration( withRestApiHydration( Block ) ),
blockName,
selector: '.wp-block-woocommerce-cart-i2',
getProps,
blockMap: getRegisteredBlockComponents( blockName ),
blockWrapper: Wrapper,
} );

View File

@ -0,0 +1,309 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import QuantitySelector from '@woocommerce/base-components/quantity-selector';
import ProductPrice from '@woocommerce/base-components/product-price';
import ProductName from '@woocommerce/base-components/product-name';
import {
useStoreCartItemQuantity,
useStoreEvents,
useStoreCart,
} from '@woocommerce/base-context/hooks';
import {
ProductBackorderBadge,
ProductImage,
ProductLowStockBadge,
ProductMetadata,
ProductSaleBadge,
} from '@woocommerce/base-components/cart-checkout';
import {
getCurrencyFromPriceResponse,
Currency,
} from '@woocommerce/price-format';
import {
__experimentalApplyCheckoutFilter,
mustContain,
} from '@woocommerce/blocks-checkout';
import Dinero from 'dinero.js';
import { useMemo } from '@wordpress/element';
import type { CartItem } from '@woocommerce/type-defs/cart';
import { objectHasProp } from '@woocommerce/types';
import { getSetting } from '@woocommerce/settings';
/**
* Convert a Dinero object with precision to store currency minor unit.
*
* @param {Dinero} priceObject Price object to convert.
* @param {Object} currency Currency data.
* @return {number} Amount with new minor unit precision.
*/
const getAmountFromRawPrice = (
priceObject: Dinero.Dinero,
currency: Currency
) => {
return priceObject.convertPrecision( currency.minorUnit ).getAmount();
};
const productPriceValidation = ( value ) => mustContain( value, '<price/>' );
/**
* Cart line item table row component.
*
* @param {Object} props
* @param {CartItem|Object} props.lineItem
*/
const CartLineItemRow = ( {
lineItem,
}: {
lineItem: CartItem | Record< string, never >;
} ): JSX.Element => {
const {
name: initialName = '',
catalog_visibility: catalogVisibility = 'visible',
short_description: shortDescription = '',
description: fullDescription = '',
low_stock_remaining: lowStockRemaining = null,
show_backorder_badge: showBackorderBadge = false,
quantity_limit: quantityLimit = 99,
permalink = '',
images = [],
variation = [],
item_data: itemData = [],
prices = {
currency_code: 'USD',
currency_minor_unit: 2,
currency_symbol: '$',
currency_prefix: '$',
currency_suffix: '',
currency_decimal_separator: '.',
currency_thousand_separator: ',',
price: '0',
regular_price: '0',
sale_price: '0',
price_range: null,
raw_prices: {
precision: 6,
price: '0',
regular_price: '0',
sale_price: '0',
},
},
totals = {
currency_code: 'USD',
currency_minor_unit: 2,
currency_symbol: '$',
currency_prefix: '$',
currency_suffix: '',
currency_decimal_separator: '.',
currency_thousand_separator: ',',
line_subtotal: '0',
line_subtotal_tax: '0',
},
extensions,
} = lineItem;
const {
quantity,
setItemQuantity,
removeItem,
isPendingDelete,
} = useStoreCartItemQuantity( lineItem );
const { dispatchStoreEvent } = useStoreEvents();
// Prepare props to pass to the __experimentalApplyCheckoutFilter filter.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { receiveCart, ...cart } = useStoreCart();
const arg = useMemo(
() => ( {
context: 'cart',
cartItem: lineItem,
cart,
} ),
[ lineItem, cart ]
);
const priceCurrency = getCurrencyFromPriceResponse( prices );
const name = __experimentalApplyCheckoutFilter( {
filterName: 'itemName',
defaultValue: initialName,
extensions,
arg,
} );
const regularAmountSingle = Dinero( {
amount: parseInt( prices.raw_prices.regular_price, 10 ),
precision: prices.raw_prices.precision,
} );
const purchaseAmountSingle = Dinero( {
amount: parseInt( prices.raw_prices.price, 10 ),
precision: prices.raw_prices.precision,
} );
const saleAmountSingle = regularAmountSingle.subtract(
purchaseAmountSingle
);
const saleAmount = saleAmountSingle.multiply( quantity );
const totalsCurrency = getCurrencyFromPriceResponse( totals );
let lineSubtotal = parseInt( totals.line_subtotal, 10 );
if ( getSetting( 'displayCartPricesIncludingTax', false ) ) {
lineSubtotal += parseInt( totals.line_subtotal_tax, 10 );
}
const subtotalPrice = Dinero( {
amount: lineSubtotal,
precision: totalsCurrency.minorUnit,
} );
const firstImage = images.length ? images[ 0 ] : {};
const isProductHiddenFromCatalog =
catalogVisibility === 'hidden' || catalogVisibility === 'search';
// Allow extensions to filter how the price is displayed. Ie: prepending or appending some values.
const productPriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'cartItemPrice',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
const subtotalPriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'subtotalPriceFormat',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
const saleBadgePriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'saleBadgePriceFormat',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
return (
<tr
className={ classnames( 'wc-block-cart-items__row', {
'is-disabled': isPendingDelete,
} ) }
>
{ /* If the image has no alt text, this link is unnecessary and can be hidden. */ }
<td
className="wc-block-cart-item__image"
aria-hidden={
! objectHasProp( firstImage, 'alt' ) || ! firstImage.alt
}
>
{ /* We don't need to make it focusable, because product name has the same link. */ }
{ isProductHiddenFromCatalog ? (
<ProductImage image={ firstImage } />
) : (
<a href={ permalink } tabIndex={ -1 }>
<ProductImage image={ firstImage } />
</a>
) }
</td>
<td className="wc-block-cart-item__product">
<ProductName
disabled={ isPendingDelete || isProductHiddenFromCatalog }
name={ name }
permalink={ permalink }
/>
{ showBackorderBadge ? (
<ProductBackorderBadge />
) : (
!! lowStockRemaining && (
<ProductLowStockBadge
lowStockRemaining={ lowStockRemaining }
/>
)
) }
<div className="wc-block-cart-item__prices">
<ProductPrice
currency={ priceCurrency }
regularPrice={ getAmountFromRawPrice(
regularAmountSingle,
priceCurrency
) }
price={ getAmountFromRawPrice(
purchaseAmountSingle,
priceCurrency
) }
format={ subtotalPriceFormat }
/>
</div>
<ProductSaleBadge
currency={ priceCurrency }
saleAmount={ getAmountFromRawPrice(
saleAmountSingle,
priceCurrency
) }
format={ saleBadgePriceFormat }
/>
<ProductMetadata
shortDescription={ shortDescription }
fullDescription={ fullDescription }
itemData={ itemData }
variation={ variation }
/>
<div className="wc-block-cart-item__quantity">
<QuantitySelector
disabled={ isPendingDelete }
quantity={ quantity }
maximum={ quantityLimit }
onChange={ ( newQuantity ) => {
setItemQuantity( newQuantity );
dispatchStoreEvent( 'cart-set-item-quantity', {
product: lineItem,
quantity: newQuantity,
} );
} }
itemName={ name }
/>
<button
className="wc-block-cart-item__remove-link"
onClick={ () => {
removeItem();
dispatchStoreEvent( 'cart-remove-item', {
product: lineItem,
quantity,
} );
} }
disabled={ isPendingDelete }
>
{ __( 'Remove item', 'woo-gutenberg-products-block' ) }
</button>
</div>
</td>
<td className="wc-block-cart-item__total">
<div className="wc-block-cart-item__total-price-and-sale-badge-wrapper">
<ProductPrice
currency={ totalsCurrency }
format={ productPriceFormat }
price={ subtotalPrice.getAmount() }
/>
{ quantity > 1 && (
<ProductSaleBadge
currency={ priceCurrency }
saleAmount={ getAmountFromRawPrice(
saleAmount,
priceCurrency
) }
format={ saleBadgePriceFormat }
/>
) }
</div>
</td>
</tr>
);
};
export default CartLineItemRow;

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CartResponseItem } from '@woocommerce/type-defs/cart-response';
/**
* Internal dependencies
*/
import CartLineItemRow from './cart-line-item-row';
const placeholderRows = [ ...Array( 3 ) ].map( ( _x, i ) => (
<CartLineItemRow lineItem={ {} } key={ i } />
) );
interface CartLineItemsTableProps {
lineItems: CartResponseItem[];
isLoading: boolean;
}
const CartLineItemsTable = ( {
lineItems = [],
isLoading = false,
}: CartLineItemsTableProps ): JSX.Element => {
const products = isLoading
? placeholderRows
: lineItems.map( ( lineItem ) => {
return (
<CartLineItemRow
key={ lineItem.key }
lineItem={ lineItem }
/>
);
} );
return (
<table className="wc-block-cart-items">
<thead>
<tr className="wc-block-cart-items__header">
<th className="wc-block-cart-items__header-image">
<span>
{ __( 'Product', 'woo-gutenberg-products-block' ) }
</span>
</th>
<th className="wc-block-cart-items__header-product">
<span>
{ __( 'Details', 'woo-gutenberg-products-block' ) }
</span>
</th>
<th className="wc-block-cart-items__header-total">
<span>
{ __( 'Total', 'woo-gutenberg-products-block' ) }
</span>
</th>
</tr>
</thead>
<tbody>{ products }</tbody>
</table>
);
};
export default CartLineItemsTable;

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { _n, sprintf } from '@wordpress/i18n';
import Title from '@woocommerce/base-components/title';
const CartLineItemsTitle = ( {
itemCount = 1,
}: {
itemCount: number;
} ): JSX.Element => {
return (
<Title headingLevel="2">
{ sprintf(
/* translators: %d is the count of items in the cart. */
_n(
'Your cart (%d item)',
'Your cart (%d items)',
itemCount,
'woo-gutenberg-products-block'
),
itemCount
) }
</Title>
);
};
export default CartLineItemsTitle;

View File

@ -0,0 +1,212 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
TotalsCoupon,
TotalsDiscount,
TotalsFooterItem,
TotalsShipping,
} from '@woocommerce/base-components/cart-checkout';
import {
Subtotal,
TotalsFees,
TotalsTaxes,
TotalsWrapper,
ExperimentalOrderMeta,
ExperimentalDiscountsMeta,
} from '@woocommerce/blocks-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import {
useStoreCartCoupons,
useStoreCart,
useStoreNotices,
} from '@woocommerce/base-context/hooks';
import classnames from 'classnames';
import {
Sidebar,
SidebarLayout,
Main,
} from '@woocommerce/base-components/sidebar-layout';
import Title from '@woocommerce/base-components/title';
import { getSetting } from '@woocommerce/settings';
import { useEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import CheckoutButton from '../checkout-button';
import CartLineItemsTitle from './cart-line-items-title';
import CartLineItemsTable from './cart-line-items-table';
import { CartExpressPayment } from '../../payment-methods';
import './style.scss';
interface CartAttributes {
hasDarkControls: boolean;
isShippingCalculatorEnabled: boolean;
checkoutPageId: number;
isPreview: boolean;
showRateAfterTaxName: boolean;
}
interface CartProps {
attributes: CartAttributes;
}
/**
* Component that renders the Cart block when user has something in cart aka "full".
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.attributes Incoming attributes for block.
*/
const Cart = ( { attributes }: CartProps ): JSX.Element => {
const {
isShippingCalculatorEnabled,
hasDarkControls,
showRateAfterTaxName,
} = attributes;
const {
cartItems,
cartFees,
cartTotals,
cartIsLoading,
cartItemsCount,
cartItemErrors,
cartNeedsPayment,
cartNeedsShipping,
} = useStoreCart();
const {
applyCoupon,
removeCoupon,
isApplyingCoupon,
isRemovingCoupon,
appliedCoupons,
} = useStoreCartCoupons();
const { addErrorNotice } = useStoreNotices();
// Ensures any cart errors listed in the API response get shown.
useEffect( () => {
cartItemErrors.forEach( ( error ) => {
addErrorNotice( decodeEntities( error.message ), {
isDismissible: true,
id: error.code,
} );
} );
}, [ addErrorNotice, cartItemErrors ] );
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const cartClassName = classnames( 'wc-block-cart', {
'wc-block-cart--is-loading': cartIsLoading,
'has-dark-controls': hasDarkControls,
} );
// Prepare props to pass to the ExperimentalOrderMeta slot fill.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { extensions, ...cart } = useStoreCart();
const slotFillProps = {
extensions,
cart,
};
const discountsSlotFillProps = {
extensions,
cart,
};
return (
<>
<CartLineItemsTitle itemCount={ cartItemsCount } />
<SidebarLayout className={ cartClassName }>
<Main className="wc-block-cart__main">
<CartLineItemsTable
lineItems={ cartItems }
isLoading={ cartIsLoading }
/>
</Main>
<Sidebar className="wc-block-cart__sidebar">
<Title
headingLevel="2"
className="wc-block-cart__totals-title"
>
{ __( 'Cart totals', 'woo-gutenberg-products-block' ) }
</Title>
<TotalsWrapper>
<Subtotal
currency={ totalsCurrency }
values={ cartTotals }
/>
<TotalsFees
currency={ totalsCurrency }
cartFees={ cartFees }
/>
<TotalsDiscount
cartCoupons={ appliedCoupons }
currency={ totalsCurrency }
isRemovingCoupon={ isRemovingCoupon }
removeCoupon={ removeCoupon }
values={ cartTotals }
/>
</TotalsWrapper>
{ getSetting( 'couponsEnabled', true ) && (
<TotalsWrapper>
<TotalsCoupon
onSubmit={ applyCoupon }
isLoading={ isApplyingCoupon }
/>
</TotalsWrapper>
) }
<ExperimentalDiscountsMeta.Slot
{ ...discountsSlotFillProps }
/>
{ cartNeedsShipping && (
<TotalsWrapper>
<TotalsShipping
showCalculator={ isShippingCalculatorEnabled }
showRateSelector={ true }
values={ cartTotals }
currency={ totalsCurrency }
/>
</TotalsWrapper>
) }
{ ! getSetting( 'displayCartPricesIncludingTax', false ) &&
parseInt( cartTotals.total_tax, 10 ) > 0 && (
<TotalsWrapper>
<TotalsTaxes
showRateAfterTaxName={
showRateAfterTaxName
}
currency={ totalsCurrency }
values={ cartTotals }
/>
</TotalsWrapper>
) }
<TotalsWrapper>
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</TotalsWrapper>
<ExperimentalOrderMeta.Slot { ...slotFillProps } />
<div className="wc-block-cart__payment-options">
{ cartNeedsPayment && <CartExpressPayment /> }
<CheckoutButton
link={ getSetting(
'page-' + attributes?.checkoutPageId,
false
) }
/>
</div>
</Sidebar>
</SidebarLayout>
</>
);
};
export default Cart;

View File

@ -0,0 +1,264 @@
.wc-block-cart {
.wc-block-components-shipping-calculator {
white-space: nowrap;
}
.wc-block-components-address-form {
.wc-block-components-text-input,
.wc-block-components-country-input,
.wc-block-components-state-input {
&:first-of-type {
margin-top: 0;
}
}
}
}
table.wc-block-cart-items,
table.wc-block-cart-items th,
table.wc-block-cart-items td {
// Override Storefront theme gray table background.
background: none !important;
// Remove borders on default themes.
border: 0;
margin: 0;
}
.editor-styles-wrapper table.wc-block-cart-items,
table.wc-block-cart-items {
width: 100%;
.wc-block-cart-items__header {
@include font-size(smaller);
text-transform: uppercase;
.wc-block-cart-items__header-image {
width: 100px;
}
.wc-block-cart-items__header-product {
visibility: hidden;
}
.wc-block-cart-items__header-total {
width: 100px;
text-align: right;
}
}
.wc-block-cart-items__row {
.wc-block-cart-item__image img {
width: 100%;
margin: 0;
}
.wc-block-cart-item__quantity {
.wc-block-cart-item__remove-link {
@include link-button;
@include font-size(smaller);
text-transform: none;
white-space: nowrap;
}
}
.wc-block-components-product-name {
display: block;
max-width: max-content;
}
.wc-block-cart-item__total {
@include font-size(regular);
text-align: right;
line-height: inherit;
}
.wc-block-components-product-metadata {
margin-bottom: 0.75em;
}
&.is-disabled {
opacity: 0.5;
pointer-events: none;
transition: opacity 200ms ease;
}
}
}
.wc-block-cart {
.wc-block-components-totals-taxes,
.wc-block-components-totals-footer-item {
margin: 0;
}
}
// Loading placeholder state.
.wc-block-cart--is-loading,
.wc-block-mini-cart__drawer.is-loading {
th span,
h2 span {
@include placeholder();
@include force-content();
min-width: 84px;
display: inline-block;
}
h2 span {
min-width: 33%;
}
.wc-block-components-product-price,
.wc-block-components-product-metadata,
.wc-block-components-quantity-selector {
@include placeholder();
}
.wc-block-components-product-name {
@include placeholder();
@include force-content();
min-width: 84px;
display: inline-block;
}
.wc-block-components-product-metadata {
margin-top: 0.25em;
min-width: 8em;
}
.wc-block-cart-item__remove-link {
visibility: hidden;
}
.wc-block-cart-item__image > a {
@include placeholder();
display: block;
}
.wc-block-components-product-price {
@include force-content();
max-width: 3em;
display: block;
margin-top: 0.25em;
}
.wc-block-cart__sidebar .components-card {
@include placeholder();
@include force-content();
min-height: 460px;
}
}
.wc-block-components-sidebar-layout.wc-block-cart--skeleton {
display: none;
}
.is-loading + .wc-block-components-sidebar-layout.wc-block-cart--skeleton {
display: flex;
}
.wc-block-cart-item__total-price-and-sale-badge-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
.wc-block-components-sale-badge {
margin-top: $gap-smallest;
}
}
.is-small,
.is-mobile {
.wc-block-cart-item__total {
.wc-block-components-sale-badge {
display: none;
}
}
}
.is-medium,
.is-small,
.is-mobile {
&.wc-block-cart {
.wc-block-components-sidebar {
.wc-block-cart__totals-title {
display: none;
}
}
}
table.wc-block-cart-items {
td {
padding: 0;
}
.wc-block-cart-items__header {
display: none;
}
.wc-block-cart-item__remove-link {
display: none;
}
.wc-block-cart-items__row {
@include with-translucent-border(0 0 1px);
display: grid;
grid-template-columns: 80px 132px;
padding: $gap 0;
.wc-block-cart-item__image {
grid-column-start: 1;
grid-row-start: 1;
padding-right: $gap;
}
.wc-block-cart-item__product {
grid-column-start: 2;
grid-column-end: 4;
grid-row-start: 1;
justify-self: stretch;
padding: 0 $gap $gap 0;
}
.wc-block-cart-item__quantity {
grid-column-start: 1;
grid-row-start: 2;
vertical-align: bottom;
padding-right: $gap;
align-self: end;
padding-top: $gap;
}
.wc-block-cart-item__total {
grid-row-start: 1;
.wc-block-components-formatted-money-amount {
display: inline-block;
}
}
}
}
}
.is-large.wc-block-cart {
.wc-block-cart-items {
@include with-translucent-border(0 0 1px);
th {
padding: 0.25rem $gap 0.25rem 0;
white-space: nowrap;
}
td {
@include with-translucent-border(1px 0 0);
padding: $gap 0 $gap $gap;
vertical-align: top;
}
th:last-child {
padding-right: 0;
}
td:last-child {
padding-right: $gap;
}
}
.wc-block-components-radio-control__input {
left: 0;
}
.wc-block-cart__totals-title {
@include text-heading();
@include font-size(smaller);
display: block;
font-weight: 600;
padding: 0.25rem 0;
text-align: right;
text-transform: uppercase;
}
.wc-block-components-sidebar {
.wc-block-components-shipping-calculator,
.wc-block-components-shipping-rates-control__package:not(.wc-block-components-panel) {
padding-left: $gap;
padding-right: $gap;
}
}
.wc-block-cart__payment-options {
padding: $gap;
}
}

View File

@ -0,0 +1,176 @@
/**
* HACKS
*
* This file contains functionality to "lock" blocks i.e. to prevent blocks being moved or deleted. This needs to be
* kept in place until native support for locking is available in WordPress (estimated WordPress 5.9).
*/
/**
* @todo Remove custom block locking (requires native WordPress support)
*/
/**
* External dependencies
*/
import {
useBlockProps,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { isTextField } from '@wordpress/dom';
import { subscribe, select as _select } from '@wordpress/data';
import { useEffect, useRef } from '@wordpress/element';
import { MutableRefObject } from 'react';
import { BACKSPACE, DELETE } from '@wordpress/keycodes';
import { hasFilter } from '@wordpress/hooks';
import { getBlockType } from '@wordpress/blocks';
/**
* Toggle class on body.
*
* @param {string} className CSS Class name.
* @param {boolean} add True to add, false to remove.
*/
const toggleBodyClass = ( className: string, add = true ) => {
if ( add ) {
window.document.body.classList.add( className );
} else {
window.document.body.classList.remove( className );
}
};
/**
* addClassToBody
*
* This components watches the current selected block and adds a class name to the body if that block is locked. If the
* current block is not locked, it removes the class name. The appended body class is used to hide UI elements to prevent
* the block from being deleted.
*
* We use a component so we can react to changes in the store.
*/
export const addClassToBody = (): void => {
if ( ! hasFilter( 'blocks.registerBlockType', 'core/lock/addAttribute' ) ) {
subscribe( () => {
const blockEditorSelect = _select( blockEditorStore );
if ( ! blockEditorSelect ) {
return;
}
const selectedBlock = blockEditorSelect.getSelectedBlock();
if ( ! selectedBlock ) {
return;
}
toggleBodyClass(
'wc-lock-selected-block--remove',
!! selectedBlock?.attributes?.lock?.remove
);
toggleBodyClass(
'wc-lock-selected-block--move',
!! selectedBlock?.attributes?.lock?.move
);
} );
}
};
const isBlockLocked = ( clientId: string ): boolean => {
if ( ! clientId ) {
return false;
}
const { getBlock } = _select( blockEditorStore );
const block = getBlock( clientId );
// If lock.remove is defined at the block instance (not using the default value)
// Then we use it.
if ( typeof block?.attributes?.lock?.remove === 'boolean' ) {
return block.attributes.lock.remove;
}
// If we don't have lock on the block instance, we check the type
const blockType = getBlockType( block.name );
if ( typeof blockType?.attributes?.lock?.default?.remove === 'boolean' ) {
return blockType?.attributes?.lock?.default?.remove;
}
// If nothing is defined, return false
return false;
};
/**
* This is a hook we use in conjunction with useBlockProps. Its goal is to check if of the block's children is locked and being deleted.
* It will stop the keydown event from propagating to stop it from being deleted via the keyboard.
*
*/
const useLockedChildren = ( {
ref,
}: {
ref: MutableRefObject< HTMLElement | undefined >;
} ): void => {
const lockInCore = hasFilter(
'blocks.registerBlockType',
'core/lock/addAttribute'
);
const node = ref.current;
return useEffect( () => {
if ( ! node || lockInCore ) {
return;
}
function onKeyDown( event: KeyboardEvent ) {
const { keyCode, target } = event;
if ( ! ( target instanceof HTMLElement ) ) {
return;
}
// We're not trying to delete something here.
if ( keyCode !== BACKSPACE && keyCode !== DELETE ) {
return;
}
// We're in a field, so we should let text be deleted.
if ( isTextField( target ) ) {
return;
}
// Typecast to fix issue with isTextField.
const targetNode = target as HTMLElement;
// Our target isn't a block.
if ( targetNode.dataset.block === undefined ) {
return;
}
const clientId = targetNode.dataset.block;
const isLocked = isBlockLocked( clientId );
// Prevent the keyboard event from propogating if it supports locking.
if ( isLocked ) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
}
node.addEventListener( 'keydown', onKeyDown, {
capture: true,
passive: false,
} );
return () => {
node.removeEventListener( 'keydown', onKeyDown, {
capture: true,
} );
};
}, [ node, lockInCore ] );
};
/**
* This hook is a light wrapper to useBlockProps, it wraps that hook plus useLockBlock to pass data between them.
*/
export const useBlockPropsWithLocking = (
props: Record< string, unknown > = {}
): Record< string, unknown > => {
const ref = useRef< HTMLElement >();
const blockProps = useBlockProps( { ref, ...props } );
useLockedChildren( {
ref,
} );
return blockProps;
};

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, cart } from '@woocommerce/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import './style.scss';
import { blockName, blockAttributes } from './attributes';
import './inner-blocks';
/**
* Register and run the Cart block.
*/
const settings = {
title: __( 'Cart i2', 'woocommerce' ),
icon: {
src: <Icon srcElement={ cart } />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __( 'Shopping cart.', 'woocommerce' ),
supports: {
align: false,
html: false,
multiple: false,
__experimentalExposeControlsToChildren: true,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: blockAttributes,
edit: Edit,
save: Save,
};
registerFeaturePluginBlockType( blockName, settings );

View File

@ -0,0 +1,26 @@
{
"name": "woocommerce/cart-express-payment-block",
"version": "1.0.0",
"title": "Express Checkout",
"description": "Provide an express payment option for your customers.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"type": "object",
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/cart-totals-block" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { useStoreCart } from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import { CartExpressPayment } from '../../../payment-methods';
const Block = (): JSX.Element | null => {
const { cartNeedsPayment } = useStoreCart();
if ( ! cartNeedsPayment ) {
return null;
}
return (
<div className="wc-block-cart__payment-options">
<CartExpressPayment />
</div>
);
};
export default Block;

View File

@ -0,0 +1,76 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Placeholder, Button } from 'wordpress-components';
import { useExpressPaymentMethods } from '@woocommerce/base-context/hooks';
import { Icon, card } from '@woocommerce/icons';
import { ADMIN_URL } from '@woocommerce/settings';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import Block from './block';
import './editor.scss';
/**
* Renders a placeholder in the editor.
*/
const NoExpressPaymentMethodsPlaceholder = () => {
return (
<Placeholder
icon={ <Icon srcElement={ card } /> }
label={ __( 'Express Checkout', 'woo-gutenberg-products-block' ) }
className="wp-block-woocommerce-checkout-express-payment-block-placeholder"
>
<span className="wp-block-woocommerce-checkout-express-payment-block-placeholder__description">
{ __(
"Your store doesn't have any Payment Methods that support the Express Checkout Block. If they are added, they will be shown here.",
'woo-gutenberg-products-block'
) }
</span>
<Button
isPrimary
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=checkout` }
target="_blank"
rel="noopener noreferrer"
className="wp-block-woocommerce-checkout-express-payment-block-placeholder__button"
>
{ __(
'Configure Payment Methods',
'woo-gutenberg-products-block'
) }
</Button>
</Placeholder>
);
};
export const Edit = (): JSX.Element | null => {
const { paymentMethods, isInitialized } = useExpressPaymentMethods();
const hasExpressPaymentMethods = Object.keys( paymentMethods ).length > 0;
const blockProps = useBlockProps( {
className: classnames( {
'wp-block-woocommerce-cart-express-payment-block--has-express-payment-methods': hasExpressPaymentMethods,
} ),
} );
if ( ! isInitialized ) {
return null;
}
return (
<div { ...blockProps }>
{ hasExpressPaymentMethods ? (
<Block />
) : (
<NoExpressPaymentMethodsPlaceholder />
) }
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};

View File

@ -0,0 +1,29 @@
// Adjust padding and margins in the editor to improve selected block outlines.
.wp-block-woocommerce-cart-express-payment-block {
margin: 14px 0 28px;
.components-placeholder__label svg {
font-size: 1em;
}
.wc-block-components-express-payment-continue-rule--checkout {
margin-bottom: 0;
}
&.wp-block-woocommerce-cart-express-payment-block--has-express-payment-methods {
padding: 14px 0;
margin: -14px 0 14px 0 !important;
position: relative;
}
}
.wp-block-woocommerce-checkout-express-payment-block-placeholder {
* {
pointer-events: all; // Overrides parent disabled component in editor context
}
.wp-block-woocommerce-checkout-express-payment-block-placeholder__description {
display: block;
margin: 0 0 1em;
}
}

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { Icon, card } from '@woocommerce/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon srcElement={ card } />,
foreground: '#874FB9',
},
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,26 @@
{
"name": "woocommerce/cart-items-block",
"version": "1.0.0",
"title": "Cart Items block",
"description": "Column containing cart items.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"type": "object",
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/filled-cart-block" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import { Main } from '@woocommerce/base-components/sidebar-layout';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import { useForcedLayout } from '../../use-forced-layout';
import { getAllowedBlocks } from '../../editor-utils';
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
const allowedBlocks = getAllowedBlocks( innerBlockAreas.CART_ITEMS );
useForcedLayout( {
clientId,
template: allowedBlocks,
} );
return (
<Main className="wc-block-cart__main">
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ allowedBlocks }
templateLock={ false }
renderAppender={ InnerBlocks.ButtonBlockAppender }
/>
</div>
</Main>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};

View File

@ -0,0 +1,14 @@
/**
* External dependencies
*/
import { Main } from '@woocommerce/base-components/sidebar-layout';
const FrontendBlock = ( {
children,
}: {
children: JSX.Element;
} ): JSX.Element => {
return <Main className="wc-block-cart__main">{ children }</Main>;
};
export default FrontendBlock;

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { Icon, column } from '@wordpress/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon icon={ column } />,
foreground: '#874FB9',
},
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,26 @@
{
"name": "woocommerce/cart-line-items-block",
"version": "1.0.0",
"title": "Cart Line Items",
"description": "Block containing current line items in Cart.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"type": "object",
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/cart-items-block" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { useStoreCart } from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import CartLineItemsTable from '../../full-cart/cart-line-items-table';
const Block = (): JSX.Element => {
const { cartItems, cartIsLoading } = useStoreCart();
return (
<CartLineItemsTable
lineItems={ cartItems }
isLoading={ cartIsLoading }
/>
);
};
export default Block;

View File

@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import Block from './block';
export const Edit = (): JSX.Element => {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<Block />
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { Icon, column } from '@wordpress/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon icon={ column } />,
foreground: '#874FB9',
},
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
export default {
showRateAfterTaxName: {
type: 'boolean',
default: getSetting( 'displayCartPricesIncludingTax', false ),
},
lock: {
type: 'object',
default: {
move: true,
remove: true,
},
},
};

View File

@ -0,0 +1,26 @@
{
"name": "woocommerce/cart-order-summary-block",
"version": "1.0.0",
"title": "Order Summary",
"description": "Show customers a summary of their order.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"type": "object",
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/cart-totals-block" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
TotalsCoupon,
TotalsDiscount,
TotalsFooterItem,
TotalsShipping,
} from '@woocommerce/base-components/cart-checkout';
import {
Subtotal,
TotalsFees,
TotalsTaxes,
TotalsWrapper,
ExperimentalOrderMeta,
ExperimentalDiscountsMeta,
} from '@woocommerce/blocks-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import {
useStoreCartCoupons,
useStoreCart,
} from '@woocommerce/base-context/hooks';
import { getSetting } from '@woocommerce/settings';
import Title from '@woocommerce/base-components/title';
/**
* Internal dependencies
*/
const Block = ( {
showRateAfterTaxName = false,
isShippingCalculatorEnabled = true,
}: {
showRateAfterTaxName: boolean;
isShippingCalculatorEnabled: boolean;
} ): JSX.Element => {
const { cartFees, cartTotals, cartNeedsShipping } = useStoreCart();
const {
applyCoupon,
removeCoupon,
isApplyingCoupon,
isRemovingCoupon,
appliedCoupons,
} = useStoreCartCoupons();
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
// Prepare props to pass to the ExperimentalOrderMeta slot fill.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { extensions, ...cart } = useStoreCart();
const slotFillProps = {
extensions,
cart,
};
const discountsSlotFillProps = {
extensions,
cart,
};
return (
<>
<Title headingLevel="2" className="wc-block-cart__totals-title">
{ __( 'Cart totals', 'woo-gutenberg-products-block' ) }
</Title>
<TotalsWrapper>
<Subtotal currency={ totalsCurrency } values={ cartTotals } />
<TotalsFees currency={ totalsCurrency } cartFees={ cartFees } />
<TotalsDiscount
cartCoupons={ appliedCoupons }
currency={ totalsCurrency }
isRemovingCoupon={ isRemovingCoupon }
removeCoupon={ removeCoupon }
values={ cartTotals }
/>
</TotalsWrapper>
{ getSetting( 'couponsEnabled', true ) && (
<TotalsWrapper>
<TotalsCoupon
onSubmit={ applyCoupon }
isLoading={ isApplyingCoupon }
/>
</TotalsWrapper>
) }
<ExperimentalDiscountsMeta.Slot { ...discountsSlotFillProps } />
{ cartNeedsShipping && (
<TotalsWrapper>
<TotalsShipping
showCalculator={ isShippingCalculatorEnabled }
showRateSelector={ true }
values={ cartTotals }
currency={ totalsCurrency }
/>
</TotalsWrapper>
) }
{ ! getSetting( 'displayCartPricesIncludingTax', false ) &&
parseInt( cartTotals.total_tax, 10 ) > 0 && (
<TotalsWrapper>
<TotalsTaxes
showRateAfterTaxName={ showRateAfterTaxName }
currency={ totalsCurrency }
values={ cartTotals }
/>
</TotalsWrapper>
) }
<TotalsWrapper>
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</TotalsWrapper>
<ExperimentalOrderMeta.Slot { ...slotFillProps } />
</>
);
};
export default Block;

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import Block from './block';
export const Edit = ( {
attributes,
setAttributes,
}: {
attributes: {
showRateAfterTaxName: boolean;
isShippingCalculatorEnabled: boolean;
lock: {
move: boolean;
remove: boolean;
};
};
setAttributes: ( attributes: Record< string, unknown > ) => void;
} ): JSX.Element => {
const { showRateAfterTaxName, isShippingCalculatorEnabled } = attributes;
const blockProps = useBlockProps();
const taxesEnabled = getSetting( 'taxesEnabled' ) as boolean;
const displayItemizedTaxes = getSetting(
'displayItemizedTaxes',
false
) as boolean;
const displayCartPricesIncludingTax = getSetting(
'displayCartPricesIncludingTax',
false
) as boolean;
return (
<div { ...blockProps }>
<InspectorControls>
{ getSetting( 'shippingEnabled', true ) && (
<PanelBody
title={ __(
'Shipping rates',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Shipping calculator',
'woo-gutenberg-products-block'
) }
help={ __(
'Allow customers to estimate shipping by entering their address.',
'woo-gutenberg-products-block'
) }
checked={ isShippingCalculatorEnabled }
onChange={ () =>
setAttributes( {
isShippingCalculatorEnabled: ! isShippingCalculatorEnabled,
} )
}
/>
</PanelBody>
) }
{ taxesEnabled &&
displayItemizedTaxes &&
! displayCartPricesIncludingTax && (
<PanelBody
title={ __(
'Taxes',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Show rate after tax name',
'woo-gutenberg-products-block'
) }
help={ __(
'Show the percentage rate alongside each tax line in the summary.',
'woo-gutenberg-products-block'
) }
checked={ showRateAfterTaxName }
onChange={ () =>
setAttributes( {
showRateAfterTaxName: ! showRateAfterTaxName,
} )
}
/>
</PanelBody>
) }
</InspectorControls>
<Disabled>
<Block
showRateAfterTaxName={ attributes.showRateAfterTaxName }
isShippingCalculatorEnabled={
attributes.isShippingCalculatorEnabled
}
/>
</Disabled>
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { Icon, totals } from '@woocommerce/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import attributes from './attributes';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon srcElement={ totals } />,
foreground: '#874FB9',
},
attributes,
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,33 @@
{
"name": "woocommerce/cart-totals-block",
"version": "1.0.0",
"title": "Cart Totals",
"description": "Column containing the cart totals.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"checkbox": {
"type": "boolean",
"default": false
},
"text": {
"type": "string",
"required": false
},
"lock": {
"type": "object",
"default": {
"remove": true
}
}
},
"parent": [ "woocommerce/filled-cart-block" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import { Sidebar } from '@woocommerce/base-components/sidebar-layout';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import './style.scss';
import { useForcedLayout } from '../../use-forced-layout';
import { getAllowedBlocks } from '../../editor-utils';
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
const allowedBlocks = getAllowedBlocks( innerBlockAreas.CART_TOTALS );
useForcedLayout( {
clientId,
template: allowedBlocks,
} );
return (
<Sidebar className="wc-block-cart__sidebar">
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ allowedBlocks }
templateLock={ false }
/>
</div>
</Sidebar>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { Sidebar } from '@woocommerce/base-components/sidebar-layout';
/**
* Internal dependencies
*/
import './style.scss';
const FrontendBlock = ( {
children,
}: {
children: JSX.Element;
} ): JSX.Element => {
return <Sidebar className="wc-block-cart__sidebar">{ children }</Sidebar>;
};
export default FrontendBlock;

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { Icon, column } from '@wordpress/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon icon={ column } />,
foreground: '#874FB9',
},
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,8 @@
.is-mobile,
.is-small,
.is-medium {
.wc-block-cart__sidebar {
margin-bottom: $gap-large;
order: 0;
}
}

View File

@ -0,0 +1,26 @@
{
"name": "woocommerce/empty-cart-block",
"version": "1.0.0",
"title": "Empty Cart",
"description": "Contains blocks that are displayed when the cart is empty.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"type": "object",
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/cart-i2" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import { useForcedLayout } from '../../use-forced-layout';
import { getAllowedBlocks } from '../../editor-utils';
import { useCartBlockContext } from '../../context';
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
const { currentView } = useCartBlockContext();
const allowedBlocks = getAllowedBlocks( innerBlockAreas.EMPTY_CART );
useForcedLayout( {
clientId,
template: allowedBlocks,
} );
return (
<div
{ ...blockProps }
hidden={ currentView !== 'woocommerce/empty-cart-block' }
>
This is the empty cart block.
<InnerBlocks
allowedBlocks={ allowedBlocks }
templateLock={ false }
/>
</div>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element';
import { dispatchEvent } from '@woocommerce/base-utils';
const FrontendBlock = ( {
children,
}: {
children: JSX.Element;
} ): JSX.Element | null => {
const { cartItems, cartIsLoading } = useStoreCart();
useEffect( () => {
dispatchEvent( 'wc-blocks_render_blocks_frontend', {
element: document.body.querySelector(
'.wp-block-woocommerce-cart'
),
} );
}, [] );
if ( ! cartIsLoading && cartItems.length === 0 ) {
return <>{ children }</>;
}
return null;
};
export default FrontendBlock;

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { Icon, removeCart } from '@woocommerce/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon srcElement={ removeCart } />,
foreground: '#874FB9',
},
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,26 @@
{
"name": "woocommerce/filled-cart-block",
"version": "1.0.0",
"title": "Filled Cart",
"description": "Contains blocks that are displayed when the cart contains products.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"type": "object",
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/cart-i2" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
/**
* Internal dependencies
*/
import { useForcedLayout } from '../../use-forced-layout';
import { getAllowedBlocks } from '../../editor-utils';
import { Columns } from './../../columns';
import './editor.scss';
import { useCartBlockContext } from '../../context';
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
const { currentView } = useCartBlockContext();
const allowedBlocks = getAllowedBlocks( innerBlockAreas.FILLED_CART );
useForcedLayout( {
clientId,
template: allowedBlocks,
} );
return (
<div
{ ...blockProps }
hidden={ currentView !== 'woocommerce/filled-cart-block' }
>
<Columns>
<SidebarLayout className={ 'wc-block-cart' }>
<InnerBlocks
allowedBlocks={ allowedBlocks }
templateLock={ false }
/>
</SidebarLayout>
</Columns>
</div>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};

View File

@ -0,0 +1,23 @@
.wp-block-woocommerce-filled-cart-block {
.wc-block-components-sidebar-layout {
display: block;
}
.block-editor-block-list__layout {
display: flex;
flex-flow: row wrap;
align-items: flex-start;
}
.wc-block-components-main,
.wc-block-components-sidebar,
.block-editor-block-list__layout {
> :first-child {
margin-top: 0;
}
}
.wp-block-woocommerce-cart-totals-block,
.wp-block-woocommerce-cart-items-block {
.block-editor-block-list__layout {
display: block;
}
}
}

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import { useStoreCart, useStoreNotices } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
const FrontendBlock = ( {
children,
}: {
children: JSX.Element;
} ): JSX.Element | null => {
const { cartItems, cartIsLoading, cartItemErrors } = useStoreCart();
const { addErrorNotice } = useStoreNotices();
// Ensures any cart errors listed in the API response get shown.
useEffect( () => {
cartItemErrors.forEach( ( error ) => {
addErrorNotice( decodeEntities( error.message ), {
isDismissible: true,
id: error.code,
} );
} );
}, [ addErrorNotice, cartItemErrors ] );
// @todo pass attributes to inner most blocks.
const hasDarkControls = false;
if ( cartIsLoading || cartItems.length >= 1 ) {
return (
<SidebarLayout
className={ classnames( 'wc-block-cart', {
'has-dark-controls': hasDarkControls,
} ) }
>
{ children }
</SidebarLayout>
);
}
return null;
};
export default FrontendBlock;

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { Icon, filledCart } from '@woocommerce/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon srcElement={ filledCart } />,
foreground: '#874FB9',
},
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,11 @@
/**
* Internal dependencies
*/
import './filled-cart-block';
import './cart-items-block';
import './cart-line-items-block';
import './cart-totals-block';
import './cart-order-summary-block';
import './cart-express-payment-block';
import './proceed-to-checkout-block';
import './empty-cart-block';

View File

@ -0,0 +1,13 @@
export default {
checkoutPageId: {
type: 'number',
default: 0,
},
lock: {
type: 'object',
default: {
move: true,
remove: true,
},
},
};

View File

@ -0,0 +1,25 @@
{
"name": "woocommerce/proceed-to-checkout-block",
"version": "1.0.0",
"title": "Proceed to checkout",
"description": "Allow customers proceed to Checkout.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false
},
"attributes": {
"lock": {
"default": {
"remove": true,
"move": true
}
}
},
"parent": [ "woocommerce/cart-totals-block" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2
}

View File

@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import CheckoutButton from '../../checkout-button';
const Block = ( {
checkoutPageId,
}: {
checkoutPageId: number;
} ): JSX.Element => {
return (
<CheckoutButton
link={ getSetting( 'page-' + checkoutPageId, false ) }
/>
);
};
export default Block;

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { useRef } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import PageSelector from '@woocommerce/editor-components/page-selector';
import { Disabled } from '@wordpress/components';
import { CART_PAGE_ID } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import Block from './block';
export const Edit = ( {
attributes,
setAttributes,
}: {
attributes: {
checkoutPageId: number;
};
setAttributes: ( attributes: Record< string, unknown > ) => void;
} ): JSX.Element => {
const blockProps = useBlockProps();
const { checkoutPageId = 0 } = attributes;
const { current: savedCheckoutPageId } = useRef( checkoutPageId );
const currentPostId = useSelect(
( select ) => {
if ( ! savedCheckoutPageId ) {
const store = select( 'core/editor' );
return store.getCurrentPostId();
}
return savedCheckoutPageId;
},
[ savedCheckoutPageId ]
);
return (
<div { ...blockProps }>
<InspectorControls>
{ ! (
currentPostId === CART_PAGE_ID && savedCheckoutPageId === 0
) && (
<PageSelector
pageId={ checkoutPageId }
setPageId={ ( id ) =>
setAttributes( { checkoutPageId: id } )
}
labels={ {
title: __(
'Proceed to Checkout button',
'woo-gutenberg-products-block'
),
default: __(
'WooCommerce Checkout Page',
'woo-gutenberg-products-block'
),
} }
/>
) }
</InspectorControls>
<Disabled>
<Block checkoutPageId={ checkoutPageId } />
</Disabled>
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};

View File

@ -0,0 +1,12 @@
/**
* External dependencies
*/
import withFilteredAttributes from '@woocommerce/base-hocs/with-filtered-attributes';
/**
* Internal dependencies
*/
import Block from './block';
import attributes from './attributes';
export default withFilteredAttributes( attributes )( Block );

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { Icon, button } from '@wordpress/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import attributes from './attributes';
import { Edit, Save } from './edit';
import metadata from './block.json';
registerFeaturePluginBlockType( metadata, {
icon: {
src: <Icon icon={ button } />,
foreground: '#874FB9',
},
attributes,
edit: Edit,
save: Save,
} );

View File

@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { lazy } from '@wordpress/element';
import { WC_BLOCKS_BUILD_URL } from '@woocommerce/block-settings';
import { registerCheckoutBlock } from '@woocommerce/blocks-checkout';
// Modify webpack publicPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
__webpack_public_path__ = WC_BLOCKS_BUILD_URL;
/**
* Internal dependencies
*/
import filledCartMetadata from './filled-cart-block/block.json';
import emptyCartMetadata from './empty-cart-block/block.json';
import cartItemsMetadata from './cart-items-block/block.json';
import cartExpressPaymentMetadata from './cart-express-payment-block/block.json';
import cartLineItemsMetadata from './cart-line-items-block/block.json';
import cartOrderSummaryMetadata from './cart-order-summary-block/block.json';
import cartTotalsMetadata from './cart-totals-block/block.json';
import cartProceedToCheckoutMetadata from './proceed-to-checkout-block/block.json';
registerCheckoutBlock( {
metadata: filledCartMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/filled-cart" */ './filled-cart-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: emptyCartMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/empty-cart" */ './empty-cart-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: filledCartMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/filled-cart" */ './filled-cart-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: emptyCartMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/empty-cart" */ './empty-cart-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: cartItemsMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/items" */ './cart-items-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: cartLineItemsMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/line-items" */ './cart-line-items-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: cartTotalsMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/totals" */ './cart-totals-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: cartOrderSummaryMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/order-summary" */ './cart-order-summary-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: cartExpressPaymentMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/express-payment" */ './cart-express-payment-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: cartProceedToCheckoutMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/checkout-button" */ './proceed-to-checkout-block/frontend'
)
),
} );

View File

@ -0,0 +1,7 @@
.wp-block-woocommerce-cart.is-loading {
display: none;
}
.wp-block-woocommerce-cart {
margin-bottom: 3em;
}

View File

@ -0,0 +1,13 @@
export type InnerBlockTemplate = [
string,
Record< string, unknown >,
InnerBlockTemplate[] | undefined
];
export interface Attributes {
isPreview: boolean;
isShippingCalculatorEnabled: boolean;
hasDarkControls: boolean;
showRateAfterTaxName: boolean;
checkoutPageId: number;
}

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { useLayoutEffect, useRef } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import {
createBlock,
getBlockType,
Block,
AttributeSource,
} from '@wordpress/blocks';
const isBlockLocked = ( {
attributes,
}: {
attributes: Record< string, AttributeSource.Attribute >;
} ) => Boolean( attributes.lock?.remove || attributes.lock?.default?.remove );
export const useForcedLayout = ( {
clientId,
template,
}: {
clientId: string;
template: Array< string >;
} ): void => {
const currentTemplate = useRef( template );
const { insertBlock } = useDispatch( 'core/block-editor' );
const { innerBlocks, templateTypes } = useSelect(
( select ) => {
return {
innerBlocks: select( 'core/block-editor' ).getBlocks(
clientId
),
templateTypes: currentTemplate.current.map( ( blockName ) =>
getBlockType( blockName )
),
};
},
[ clientId, currentTemplate ]
);
/**
* If the current inner blocks differ from the registered blocks, push the differences.
*
*/
useLayoutEffect( () => {
if ( ! clientId ) {
return;
}
// Missing check to see if registered block is 'forced'
templateTypes.forEach( ( block: Block | undefined ) => {
if (
block &&
isBlockLocked( block ) &&
! innerBlocks.find(
( { name }: { name: string } ) => name === block.name
)
) {
const newBlock = createBlock( block.name );
insertBlock( newBlock, innerBlocks.length, clientId, false );
}
} );
}, [ clientId, innerBlocks, insertBlock, templateTypes ] );
};

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { useDispatch, select } from '@wordpress/data';
import { Toolbar, ToolbarDropdownMenu } from '@wordpress/components';
import { Icon, eye } from '@woocommerce/icons';
import { store as blockEditorStore } from '@wordpress/block-editor';
interface View {
view: string;
label: string;
icon: string | JSX.Element;
}
export const useViewSwitcher = (
clientId: string,
views: View[]
): {
currentView: string;
component: () => JSX.Element;
} => {
const initialView = views[ 0 ];
const [ currentView, setCurrentView ] = useState( initialView );
const { selectBlock } = useDispatch( 'core/block-editor' );
const { getBlock } = select( blockEditorStore );
const ViewSwitcherComponent = () => (
<Toolbar>
<ToolbarDropdownMenu
label={ __( 'Switch view', 'woo-gutenberg-products-block' ) }
text={ currentView.label }
icon={
<Icon srcElement={ eye } style={ { marginRight: '8px' } } />
}
controls={ views.map( ( view ) => ( {
...view,
title: view.label,
onClick: () => {
setCurrentView( view );
selectBlock(
getBlock( clientId ).innerBlocks.find(
( block: { name: string } ) =>
block.name === view.view
)?.clientId || clientId
);
},
} ) ) }
/>
</Toolbar>
);
return {
currentView: currentView.view,
component: ViewSwitcherComponent,
};
};
export default useViewSwitcher;

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
const blockAttributes = {
isPreview: {
type: 'boolean',
default: false,
save: false,
},
isShippingCalculatorEnabled: {
type: 'boolean',
default: getSetting( 'isShippingCalculatorEnabled', true ),
},
checkoutPageId: {
type: 'number',
default: 0,
},
hasDarkControls: {
type: 'boolean',
default: getSetting( 'hasDarkEditorStyleSupport', false ),
},
showRateAfterTaxName: {
type: 'boolean',
default: true,
},
};
export default blockAttributes;

View File

@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { dispatch } from '@wordpress/data';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect, RawHTML } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { ValidationContextProvider } from '@woocommerce/base-context';
import {
dispatchEvent,
translateJQueryEventToNative,
} from '@woocommerce/base-utils';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
/**
* Internal dependencies
*/
import FullCart from './full-cart';
const EmptyCart = ( { content } ) => {
useEffect( () => {
dispatchEvent( 'wc-blocks_render_blocks_frontend', {
element: document.body.querySelector(
'.wp-block-woocommerce-cart'
),
} );
}, [] );
return <RawHTML>{ content }</RawHTML>;
};
const Block = ( { emptyCart, attributes, scrollToTop } ) => {
const { cartItems, cartIsLoading } = useStoreCart();
useEffect( () => {
const invalidateCartData = ( e ) => {
const eventDetail = e.detail;
if ( ! eventDetail || ! eventDetail.preserveCartData ) {
dispatch( storeKey ).invalidateResolutionForStore();
}
scrollToTop();
};
// Make it so we can read jQuery events triggered by WC Core elements.
const removeJQueryAddedToCartEvent = translateJQueryEventToNative(
'added_to_cart',
'wc-blocks_added_to_cart'
);
const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative(
'removed_from_cart',
'wc-blocks_removed_from_cart'
);
document.body.addEventListener(
'wc-blocks_added_to_cart',
invalidateCartData
);
document.body.addEventListener(
'wc-blocks_removed_from_cart',
invalidateCartData
);
return () => {
removeJQueryAddedToCartEvent();
removeJQueryRemovedFromCartEvent();
document.body.removeEventListener(
'wc-blocks_added_to_cart',
invalidateCartData
);
document.body.removeEventListener(
'wc-blocks_removed_from_cart',
invalidateCartData
);
};
}, [ scrollToTop ] );
return (
<>
{ ! cartIsLoading && cartItems.length === 0 ? (
<EmptyCart content={ emptyCart } />
) : (
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
<ValidationContextProvider>
<FullCart attributes={ attributes } />
</ValidationContextProvider>
</LoadingMask>
) }
</>
);
};
export default withScrollToTop( Block );

View File

@ -0,0 +1,104 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
import { PaymentMethodIcons } from '@woocommerce/base-components/cart-checkout';
import Button from '@woocommerce/base-components/button';
import { CHECKOUT_URL } from '@woocommerce/block-settings';
import { useCheckoutContext } from '@woocommerce/base-context';
import { usePaymentMethods } from '@woocommerce/base-context/hooks';
import { usePositionRelativeToViewport } from '@woocommerce/base-hooks';
import type {
PaymentMethods,
PaymentMethodIcons as PaymentMethodIconsType,
} from '@woocommerce/type-defs/payments';
/**
* Internal dependencies
*/
import './style.scss';
const getIconsFromPaymentMethods = (
paymentMethods: PaymentMethods
): PaymentMethodIconsType => {
return Object.values( paymentMethods ).reduce( ( acc, paymentMethod ) => {
if ( paymentMethod.icons !== null ) {
acc = acc.concat( paymentMethod.icons );
}
return acc;
}, [] as PaymentMethodIconsType );
};
/**
* Checkout button rendered in the full cart page.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.link What the button is linked to.
*/
const CheckoutButton = ( { link }: { link: string } ): JSX.Element => {
const { isCalculating } = useCheckoutContext();
const [
positionReferenceElement,
positionRelativeToViewport,
] = usePositionRelativeToViewport();
const [ showSpinner, setShowSpinner ] = useState( false );
const { paymentMethods } = usePaymentMethods();
useEffect( () => {
// Add a listener to remove the spinner on the checkout button, so the saved page snapshot does not
// contain the spinner class. See https://archive.is/lOEW0 for why this is needed for Safari.
if (
typeof global.addEventListener !== 'function' ||
typeof global.removeEventListener !== 'function'
) {
return;
}
const hideSpinner = () => {
setShowSpinner( false );
};
global.addEventListener( 'pageshow', hideSpinner );
return () => {
global.removeEventListener( 'pageshow', hideSpinner );
};
}, [] );
const submitContainerContents = (
<>
<Button
className="wc-block-cart__submit-button"
href={ link || CHECKOUT_URL }
disabled={ isCalculating }
onClick={ () => setShowSpinner( true ) }
showSpinner={ showSpinner }
>
{ __( 'Proceed to Checkout', 'woo-gutenberg-products-block' ) }
</Button>
<PaymentMethodIcons
icons={ getIconsFromPaymentMethods( paymentMethods ) }
/>
</>
);
return (
<div className="wc-block-cart__submit">
{ positionReferenceElement }
{ /* The non-sticky container must always be visible because it gives height to its parent, which is required to calculate when it becomes visible in the viewport. */ }
<div className="wc-block-cart__submit-container">
{ submitContainerContents }
</div>
{ /* If the positionReferenceElement is below the viewport, display the sticky container. */ }
{ positionRelativeToViewport === 'below' && (
<div className="wc-block-cart__submit-container wc-block-cart__submit-container--sticky">
{ submitContainerContents }
</div>
) }
</div>
);
};
export default CheckoutButton;

View File

@ -0,0 +1,56 @@
.wc-block-cart__submit {
position: relative;
}
.wc-block-cart__submit-container {
padding-bottom: $gap;
}
.wc-block-cart__submit-button {
width: 100%;
margin: 0 0 $gap;
&:last-child {
margin-bottom: 0;
}
}
.is-mobile,
.is-small,
.is-medium {
.wc-block-cart__submit-container:not(.wc-block-cart__submit-container--sticky) {
padding-left: 0;
padding-right: 0;
padding-top: 0;
}
}
@include breakpoint(">782px") {
.wc-block-cart__submit-container--sticky {
display: none;
}
}
@include breakpoint("<782px") {
.wc-block-cart__submit-container--sticky {
background: $white;
bottom: 0;
left: 0;
padding: $gap;
position: fixed;
width: 100%;
z-index: 9999;
&::before {
box-shadow: 0 -10px 20px 10px currentColor;
color: transparentize($gray-400, 0.5);
content: "";
height: 100%;
left: 0;
position: absolute;
right: 0;
top: 0;
}
}
}

View File

@ -0,0 +1,246 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { CartCheckoutFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt';
import { InspectorControls } from '@wordpress/block-editor';
import {
Disabled,
PanelBody,
ToggleControl,
Notice,
} from '@wordpress/components';
import PropTypes from 'prop-types';
import { CartCheckoutCompatibilityNotice } from '@woocommerce/editor-components/compatibility-notices';
import ViewSwitcher from '@woocommerce/editor-components/view-switcher';
import PageSelector from '@woocommerce/editor-components/page-selector';
import { CART_PAGE_ID } from '@woocommerce/block-settings';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import {
EditorProvider,
useEditorContext,
CartProvider,
} from '@woocommerce/base-context';
import { createInterpolateElement, useRef } from '@wordpress/element';
import { getAdminLink, getSetting } from '@woocommerce/settings';
import { previewCart } from '@woocommerce/resource-previews';
/**
* Internal dependencies
*/
import Block from './block.js';
import EmptyCartEdit from './empty-cart-edit';
import './editor.scss';
const BlockSettings = ( { attributes, setAttributes } ) => {
const {
isShippingCalculatorEnabled,
checkoutPageId,
hasDarkControls,
showRateAfterTaxName,
} = attributes;
const { currentPostId } = useEditorContext();
const { current: savedCheckoutPageId } = useRef( checkoutPageId );
return (
<InspectorControls>
{ currentPostId !== CART_PAGE_ID && (
<Notice
className="wc-block-cart__page-notice"
isDismissible={ false }
status="warning"
>
{ createInterpolateElement(
__(
'If you would like to use this block as your default cart you must update your <a>page settings in WooCommerce</a>.',
'woocommerce'
),
{
a: (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={ getAdminLink(
'admin.php?page=wc-settings&tab=advanced'
) }
target="_blank"
rel="noopener noreferrer"
/>
),
}
) }
</Notice>
) }
{ getSetting( 'shippingEnabled', true ) && (
<PanelBody
title={ __(
'Shipping rates',
'woocommerce'
) }
>
<ToggleControl
label={ __(
'Shipping calculator',
'woocommerce'
) }
help={ __(
'Allow customers to estimate shipping by entering their address.',
'woocommerce'
) }
checked={ isShippingCalculatorEnabled }
onChange={ () =>
setAttributes( {
isShippingCalculatorEnabled: ! isShippingCalculatorEnabled,
} )
}
/>
</PanelBody>
) }
{ getSetting( 'taxesEnabled' ) &&
getSetting( 'displayItemizedTaxes', false ) &&
! getSetting( 'displayCartPricesIncludingTax', false ) && (
<PanelBody
title={ __( 'Taxes', 'woocommerce' ) }
>
<ToggleControl
label={ __(
'Show rate after tax name',
'woocommerce'
) }
help={ __(
'Show the percentage rate alongside each tax line in the summary.',
'woocommerce'
) }
checked={ showRateAfterTaxName }
onChange={ () =>
setAttributes( {
showRateAfterTaxName: ! showRateAfterTaxName,
} )
}
/>
</PanelBody>
) }
{ ! (
currentPostId === CART_PAGE_ID && savedCheckoutPageId === 0
) && (
<PageSelector
pageId={ checkoutPageId }
setPageId={ ( id ) =>
setAttributes( { checkoutPageId: id } )
}
labels={ {
title: __(
'Proceed to Checkout button',
'woocommerce'
),
default: __(
'WooCommerce Checkout Page',
'woocommerce'
),
} }
/>
) }
<PanelBody title={ __( 'Style', 'woocommerce' ) }>
<ToggleControl
label={ __(
'Dark mode inputs',
'woocommerce'
) }
help={ __(
'Inputs styled specifically for use on dark background colors.',
'woocommerce'
) }
checked={ hasDarkControls }
onChange={ () =>
setAttributes( {
hasDarkControls: ! hasDarkControls,
} )
}
/>
</PanelBody>
<CartCheckoutFeedbackPrompt />
</InspectorControls>
);
};
/**
* Component to handle edit mode of "Cart Block".
*
* Note: We need to always render `<InnerBlocks>` in the editor. Otherwise,
* if the user saves the page without having triggered the 'Empty Cart'
* view, inner blocks would not be saved and they wouldn't be visible
* in the frontend.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.className CSS class used.
* @param {Object} props.attributes Attributes available.
* @param {function(any):any} props.setAttributes Setter for attributes.
*/
const CartEditor = ( { className, attributes, setAttributes } ) => {
return (
<div
className={ classnames( className, 'wp-block-woocommerce-cart', {
'is-editor-preview': attributes.isPreview,
} ) }
>
<ViewSwitcher
label={ __( 'Edit', 'woocommerce' ) }
views={ [
{
value: 'full',
name: __( 'Full Cart', 'woocommerce' ),
},
{
value: 'empty',
name: __(
'Empty Cart',
'woocommerce'
),
},
] }
defaultView={ 'full' }
render={ ( currentView ) => (
<BlockErrorBoundary
header={ __(
'Cart Block Error',
'woocommerce'
) }
text={ __(
'There was an error whilst rendering the cart block. If this problem continues, try re-creating the block.',
'woocommerce'
) }
showErrorMessage={ true }
errorMessagePrefix={ __(
'Error message:',
'woocommerce'
) }
>
{ currentView === 'full' && (
<>
<EditorProvider previewData={ { previewCart } }>
<BlockSettings
attributes={ attributes }
setAttributes={ setAttributes }
/>
<Disabled>
<CartProvider>
<Block attributes={ attributes } />
</CartProvider>
</Disabled>
</EditorProvider>
<EmptyCartEdit hidden={ true } />
</>
) }
{ currentView === 'empty' && <EmptyCartEdit /> }
</BlockErrorBoundary>
) }
/>
<CartCheckoutCompatibilityNotice blockName="cart" />
</div>
);
};
CartEditor.propTypes = {
className: PropTypes.string,
};
export default CartEditor;

View File

@ -0,0 +1,8 @@
.wc-block-cart__page-notice {
margin: 0;
}
.wp-block-woocommerce-cart.is-editor-preview {
max-height: 1000px;
overflow: hidden;
}

View File

@ -0,0 +1 @@
export default 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzgiIGhlaWdodD0iMzgiIHZpZXdCb3g9IjAgMCAzOCAzOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE5IDBDOC41MDQwMyAwIDAgOC41MDQwMyAwIDE5QzAgMjkuNDk2IDguNTA0MDMgMzggMTkgMzhDMjkuNDk2IDM4IDM4IDI5LjQ5NiAzOCAxOUMzOCA4LjUwNDAzIDI5LjQ5NiAwIDE5IDBaTTI1LjEyOSAxMi44NzFDMjYuNDg1MSAxMi44NzEgMjcuNTgwNiAxMy45NjY1IDI3LjU4MDYgMTUuMzIyNkMyNy41ODA2IDE2LjY3ODYgMjYuNDg1MSAxNy43NzQyIDI1LjEyOSAxNy43NzQyQzIzLjc3MyAxNy43NzQyIDIyLjY3NzQgMTYuNjc4NiAyMi42Nzc0IDE1LjMyMjZDMjIuNjc3NCAxMy45NjY1IDIzLjc3MyAxMi44NzEgMjUuMTI5IDEyLjg3MVpNMTEuNjQ1MiAzMS4yNTgxQzkuNjE0OTIgMzEuMjU4MSA3Ljk2Nzc0IDI5LjY0OTIgNy45Njc3NCAyNy42NTczQzcuOTY3NzQgMjYuMTI1IDEwLjE1MTIgMjMuMDI5OCAxMS4xNTQ4IDIxLjY5NjhDMTEuNCAyMS4zNjczIDExLjg5MDMgMjEuMzY3MyAxMi4xMzU1IDIxLjY5NjhDMTMuMTM5MSAyMy4wMjk4IDE1LjMyMjYgMjYuMTI1IDE1LjMyMjYgMjcuNjU3M0MxNS4zMjI2IDI5LjY0OTIgMTMuNjc1NCAzMS4yNTgxIDExLjY0NTIgMzEuMjU4MVpNMTIuODcxIDE3Ljc3NDJDMTEuNTE0OSAxNy43NzQyIDEwLjQxOTQgMTYuNjc4NiAxMC40MTk0IDE1LjMyMjZDMTAuNDE5NCAxMy45NjY1IDExLjUxNDkgMTIuODcxIDEyLjg3MSAxMi44NzFDMTQuMjI3IDEyLjg3MSAxNS4zMjI2IDEzLjk2NjUgMTUuMzIyNiAxNS4zMjI2QzE1LjMyMjYgMTYuNjc4NiAxNC4yMjcgMTcuNzc0MiAxMi44NzEgMTcuNzc0MlpNMjUuOTEwNSAyOS41ODc5QzI0LjE5NDQgMjcuNTM0NyAyMS42NzM4IDI2LjM1NDggMTkgMjYuMzU0OEMxNy4zNzU4IDI2LjM1NDggMTcuMzc1OCAyMy45MDMyIDE5IDIzLjkwMzJDMjIuNDAxNiAyMy45MDMyIDI1LjYxMTcgMjUuNDA0OCAyNy43ODc1IDI4LjAyNUMyOC44NDQ4IDI5LjI4MTUgMjYuOTI5NCAzMC44MjE0IDI1LjkxMDUgMjkuNTg3OVoiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo=';

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/block-editor';
import { SHOP_URL } from '@woocommerce/block-settings';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import iconDataUri from './icon-data-uri.js';
import './style.scss';
const templateItemBrowseStore = SHOP_URL
? [
'core/paragraph',
{
align: 'center',
content: sprintf(
/* translators: %s is the link to the store product directory. */
__(
'<a href="%s">Browse store</a>.',
'woocommerce'
),
SHOP_URL
),
dropCap: false,
},
]
: null;
const templateItems = [
[
'core/image',
{
align: 'center',
url: iconDataUri,
sizeSlug: 'small',
},
],
[
'core/heading',
{
textAlign: 'center',
content: __(
'Your cart is currently empty!',
'woocommerce'
),
level: 2,
className: 'wc-block-cart__empty-cart__title',
},
],
templateItemBrowseStore,
[
'core/separator',
{
className: 'is-style-dots',
},
],
[
'core/heading',
{
textAlign: 'center',
content: __( 'New in store', 'woocommerce' ),
level: 2,
},
],
[
'woocommerce/product-new',
{
columns: 3,
rows: 1,
},
],
].filter( Boolean );
/**
* Component to handle edit mode for the Cart block when cart is empty.
*
* @param {Object} props Incoming props for the component.
* @param {boolean} props.hidden Whether this component is hidden or not.
*/
const EmptyCartEdit = ( { hidden = false } ) => {
return (
<div hidden={ hidden }>
<InnerBlocks
templateInsertUpdatesSelection={ false }
template={ templateItems }
/>
</div>
);
};
EmptyCartEdit.propTypes = {
hidden: PropTypes.bool,
};
export default EmptyCartEdit;

View File

@ -0,0 +1,4 @@
.wc-block-cart__empty-cart__title,
.editor-styles-wrapper .wc-block-cart__empty-cart__title {
font-size: inherit;
}

View File

@ -0,0 +1,74 @@
/**
* External dependencies
*/
import {
withStoreCartApiHydration,
withRestApiHydration,
} from '@woocommerce/block-hocs';
import { __ } from '@wordpress/i18n';
import {
StoreNoticesProvider,
StoreSnackbarNoticesProvider,
CartProvider,
} from '@woocommerce/base-context/providers';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import {
renderFrontend,
getValidBlockAttributes,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import Block from './block.js';
import blockAttributes from './attributes';
const reloadPage = () => void window.location.reload( true );
/**
* Wrapper component to supply API data and show empty cart view as needed.
*
* @param {*} props
*/
const CartFrontend = ( props ) => {
return (
<StoreSnackbarNoticesProvider context="wc/cart">
<StoreNoticesProvider context="wc/cart">
<SlotFillProvider>
<CartProvider>
<Block { ...props } />
</CartProvider>
</SlotFillProvider>
</StoreNoticesProvider>
</StoreSnackbarNoticesProvider>
);
};
const getProps = ( el ) => {
return {
emptyCart: el.innerHTML,
attributes: getValidBlockAttributes( blockAttributes, el.dataset ),
};
};
const getErrorBoundaryProps = () => {
return {
header: __( 'Something went wrong…', 'woocommerce' ),
text: __(
'The cart has encountered an unexpected error. If the error persists, please get in touch with us for help.',
'woocommerce'
),
showErrorMessage: CURRENT_USER_IS_ADMIN,
button: (
<button className="wc-block-button" onClick={ reloadPage }>
{ __( 'Reload the page', 'woocommerce' ) }
</button>
),
};
};
renderFrontend( {
selector: '.wp-block-woocommerce-cart',
Block: withStoreCartApiHydration( withRestApiHydration( CartFrontend ) ),
getProps,
getErrorBoundaryProps,
} );

View File

@ -0,0 +1,332 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { __, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import QuantitySelector from '@woocommerce/base-components/quantity-selector';
import ProductPrice from '@woocommerce/base-components/product-price';
import ProductName from '@woocommerce/base-components/product-name';
import {
useStoreCartItemQuantity,
useStoreEvents,
useStoreCart,
} from '@woocommerce/base-context/hooks';
import {
ProductBackorderBadge,
ProductImage,
ProductLowStockBadge,
ProductMetadata,
ProductSaleBadge,
} from '@woocommerce/base-components/cart-checkout';
import {
getCurrencyFromPriceResponse,
Currency,
} from '@woocommerce/price-format';
import {
__experimentalApplyCheckoutFilter,
mustContain,
} from '@woocommerce/blocks-checkout';
import Dinero from 'dinero.js';
import { forwardRef, useMemo } from '@wordpress/element';
import type { CartItem } from '@woocommerce/type-defs/cart';
import { objectHasProp } from '@woocommerce/types';
import { getSetting } from '@woocommerce/settings';
/**
* Convert a Dinero object with precision to store currency minor unit.
*
* @param {Dinero} priceObject Price object to convert.
* @param {Object} currency Currency data.
* @return {number} Amount with new minor unit precision.
*/
const getAmountFromRawPrice = (
priceObject: Dinero.Dinero,
currency: Currency
) => {
return priceObject.convertPrecision( currency.minorUnit ).getAmount();
};
const productPriceValidation = ( value ) => mustContain( value, '<price/>' );
interface CartLineItemRowProps {
lineItem: CartItem | Record< string, never >;
onRemove?: () => void;
tabIndex?: number;
}
/**
* Cart line item table row component.
*/
const CartLineItemRow = forwardRef< HTMLTableRowElement, CartLineItemRowProps >(
(
{ lineItem, onRemove = () => void null, tabIndex = null },
ref
): JSX.Element => {
const {
name: initialName = '',
catalog_visibility: catalogVisibility = 'visible',
short_description: shortDescription = '',
description: fullDescription = '',
low_stock_remaining: lowStockRemaining = null,
show_backorder_badge: showBackorderBadge = false,
quantity_limit: quantityLimit = 99,
permalink = '',
images = [],
variation = [],
item_data: itemData = [],
prices = {
currency_code: 'USD',
currency_minor_unit: 2,
currency_symbol: '$',
currency_prefix: '$',
currency_suffix: '',
currency_decimal_separator: '.',
currency_thousand_separator: ',',
price: '0',
regular_price: '0',
sale_price: '0',
price_range: null,
raw_prices: {
precision: 6,
price: '0',
regular_price: '0',
sale_price: '0',
},
},
totals = {
currency_code: 'USD',
currency_minor_unit: 2,
currency_symbol: '$',
currency_prefix: '$',
currency_suffix: '',
currency_decimal_separator: '.',
currency_thousand_separator: ',',
line_subtotal: '0',
line_subtotal_tax: '0',
},
extensions,
} = lineItem;
const {
quantity,
setItemQuantity,
removeItem,
isPendingDelete,
} = useStoreCartItemQuantity( lineItem );
const { dispatchStoreEvent } = useStoreEvents();
// Prepare props to pass to the __experimentalApplyCheckoutFilter filter.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { receiveCart, ...cart } = useStoreCart();
const arg = useMemo(
() => ( {
context: 'cart',
cartItem: lineItem,
cart,
} ),
[ lineItem, cart ]
);
const priceCurrency = getCurrencyFromPriceResponse( prices );
const name = __experimentalApplyCheckoutFilter( {
filterName: 'itemName',
defaultValue: initialName,
extensions,
arg,
} );
const regularAmountSingle = Dinero( {
amount: parseInt( prices.raw_prices.regular_price, 10 ),
precision: prices.raw_prices.precision,
} );
const purchaseAmountSingle = Dinero( {
amount: parseInt( prices.raw_prices.price, 10 ),
precision: prices.raw_prices.precision,
} );
const saleAmountSingle = regularAmountSingle.subtract(
purchaseAmountSingle
);
const saleAmount = saleAmountSingle.multiply( quantity );
const totalsCurrency = getCurrencyFromPriceResponse( totals );
let lineSubtotal = parseInt( totals.line_subtotal, 10 );
if ( getSetting( 'displayCartPricesIncludingTax', false ) ) {
lineSubtotal += parseInt( totals.line_subtotal_tax, 10 );
}
const subtotalPrice = Dinero( {
amount: lineSubtotal,
precision: totalsCurrency.minorUnit,
} );
const firstImage = images.length ? images[ 0 ] : {};
const isProductHiddenFromCatalog =
catalogVisibility === 'hidden' || catalogVisibility === 'search';
// Allow extensions to filter how the price is displayed. Ie: prepending or appending some values.
const productPriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'cartItemPrice',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
const subtotalPriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'subtotalPriceFormat',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
const saleBadgePriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'saleBadgePriceFormat',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
return (
<tr
className={ classnames( 'wc-block-cart-items__row', {
'is-disabled': isPendingDelete,
} ) }
ref={ ref }
tabIndex={ tabIndex }
>
{ /* If the image has no alt text, this link is unnecessary and can be hidden. */ }
<td
className="wc-block-cart-item__image"
aria-hidden={
! objectHasProp( firstImage, 'alt' ) || ! firstImage.alt
}
>
{ /* We don't need to make it focusable, because product name has the same link. */ }
{ isProductHiddenFromCatalog ? (
<ProductImage image={ firstImage } />
) : (
<a href={ permalink } tabIndex={ -1 }>
<ProductImage image={ firstImage } />
</a>
) }
</td>
<td className="wc-block-cart-item__product">
<ProductName
disabled={
isPendingDelete || isProductHiddenFromCatalog
}
name={ name }
permalink={ permalink }
/>
{ showBackorderBadge ? (
<ProductBackorderBadge />
) : (
!! lowStockRemaining && (
<ProductLowStockBadge
lowStockRemaining={ lowStockRemaining }
/>
)
) }
<div className="wc-block-cart-item__prices">
<ProductPrice
currency={ priceCurrency }
regularPrice={ getAmountFromRawPrice(
regularAmountSingle,
priceCurrency
) }
price={ getAmountFromRawPrice(
purchaseAmountSingle,
priceCurrency
) }
format={ subtotalPriceFormat }
/>
</div>
<ProductSaleBadge
currency={ priceCurrency }
saleAmount={ getAmountFromRawPrice(
saleAmountSingle,
priceCurrency
) }
format={ saleBadgePriceFormat }
/>
<ProductMetadata
shortDescription={ shortDescription }
fullDescription={ fullDescription }
itemData={ itemData }
variation={ variation }
/>
<div className="wc-block-cart-item__quantity">
<QuantitySelector
disabled={ isPendingDelete }
quantity={ quantity }
maximum={ quantityLimit }
onChange={ ( newQuantity ) => {
setItemQuantity( newQuantity );
dispatchStoreEvent( 'cart-set-item-quantity', {
product: lineItem,
quantity: newQuantity,
} );
} }
itemName={ name }
/>
<button
className="wc-block-cart-item__remove-link"
onClick={ () => {
onRemove();
removeItem();
dispatchStoreEvent( 'cart-remove-item', {
product: lineItem,
quantity,
} );
speak(
sprintf(
/* translators: %s refers to the item name in the cart. */
__(
'%s has been removed from your cart.',
'woo-gutenberg-products-block'
),
name
)
);
} }
disabled={ isPendingDelete }
>
{ __(
'Remove item',
'woo-gutenberg-products-block'
) }
</button>
</div>
</td>
<td className="wc-block-cart-item__total">
<div className="wc-block-cart-item__total-price-and-sale-badge-wrapper">
<ProductPrice
currency={ totalsCurrency }
format={ productPriceFormat }
price={ subtotalPrice.getAmount() }
/>
{ quantity > 1 && (
<ProductSaleBadge
currency={ priceCurrency }
saleAmount={ getAmountFromRawPrice(
saleAmount,
priceCurrency
) }
format={ saleBadgePriceFormat }
/>
) }
</div>
</td>
</tr>
);
}
);
export default CartLineItemRow;

View File

@ -0,0 +1,95 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CartResponseItem } from '@woocommerce/type-defs/cart-response';
import { createRef, useEffect, useRef } from '@wordpress/element';
import type { RefObject } from 'react';
/**
* Internal dependencies
*/
import CartLineItemRow from './cart-line-item-row';
const placeholderRows = [ ...Array( 3 ) ].map( ( _x, i ) => (
<CartLineItemRow lineItem={ {} } key={ i } />
) );
interface CartLineItemsTableProps {
lineItems: CartResponseItem[];
isLoading: boolean;
}
const setRefs = ( lineItems: CartResponseItem[] ) => {
const refs = {} as Record< string, RefObject< HTMLTableRowElement > >;
lineItems.forEach( ( { key } ) => {
refs[ key ] = createRef();
} );
return refs;
};
const CartLineItemsTable = ( {
lineItems = [],
isLoading = false,
}: CartLineItemsTableProps ): JSX.Element => {
const tableRef = useRef< HTMLTableElement | null >( null );
const rowRefs = useRef( setRefs( lineItems ) );
useEffect( () => {
rowRefs.current = setRefs( lineItems );
}, [ lineItems ] );
const onRemoveRow = ( nextItemKey: string | null ) => () => {
if (
rowRefs?.current &&
nextItemKey &&
rowRefs.current[ nextItemKey ].current instanceof HTMLElement
) {
( rowRefs.current[ nextItemKey ].current as HTMLElement ).focus();
} else if ( tableRef.current instanceof HTMLElement ) {
tableRef.current.focus();
}
};
const products = isLoading
? placeholderRows
: lineItems.map( ( lineItem, i ) => {
const nextItemKey =
lineItems.length > i + 1 ? lineItems[ i + 1 ].key : null;
return (
<CartLineItemRow
key={ lineItem.key }
lineItem={ lineItem }
onRemove={ onRemoveRow( nextItemKey ) }
ref={ rowRefs.current[ lineItem.key ] }
tabIndex={ -1 }
/>
);
} );
return (
<table className="wc-block-cart-items" ref={ tableRef } tabIndex={ -1 }>
<thead>
<tr className="wc-block-cart-items__header">
<th className="wc-block-cart-items__header-image">
<span>
{ __( 'Product', 'woo-gutenberg-products-block' ) }
</span>
</th>
<th className="wc-block-cart-items__header-product">
<span>
{ __( 'Details', 'woo-gutenberg-products-block' ) }
</span>
</th>
<th className="wc-block-cart-items__header-total">
<span>
{ __( 'Total', 'woo-gutenberg-products-block' ) }
</span>
</th>
</tr>
</thead>
<tbody>{ products }</tbody>
</table>
);
};
export default CartLineItemsTable;

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { _n, sprintf } from '@wordpress/i18n';
import Title from '@woocommerce/base-components/title';
const CartLineItemsTitle = ( {
itemCount = 1,
}: {
itemCount: number;
} ): JSX.Element => {
return (
<Title headingLevel="2">
{ sprintf(
/* translators: %d is the count of items in the cart. */
_n(
'Your cart (%d item)',
'Your cart (%d items)',
itemCount,
'woo-gutenberg-products-block'
),
itemCount
) }
</Title>
);
};
export default CartLineItemsTitle;

View File

@ -0,0 +1,212 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
TotalsCoupon,
TotalsDiscount,
TotalsFooterItem,
TotalsShipping,
} from '@woocommerce/base-components/cart-checkout';
import {
Subtotal,
TotalsFees,
TotalsTaxes,
TotalsWrapper,
ExperimentalOrderMeta,
ExperimentalDiscountsMeta,
} from '@woocommerce/blocks-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import {
useStoreCartCoupons,
useStoreCart,
useStoreNotices,
} from '@woocommerce/base-context/hooks';
import classnames from 'classnames';
import {
Sidebar,
SidebarLayout,
Main,
} from '@woocommerce/base-components/sidebar-layout';
import Title from '@woocommerce/base-components/title';
import { getSetting } from '@woocommerce/settings';
import { useEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import CheckoutButton from '../checkout-button';
import CartLineItemsTitle from './cart-line-items-title';
import CartLineItemsTable from './cart-line-items-table';
import { CartExpressPayment } from '../../payment-methods';
import './style.scss';
interface CartAttributes {
hasDarkControls: boolean;
isShippingCalculatorEnabled: boolean;
checkoutPageId: number;
isPreview: boolean;
showRateAfterTaxName: boolean;
}
interface CartProps {
attributes: CartAttributes;
}
/**
* Component that renders the Cart block when user has something in cart aka "full".
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.attributes Incoming attributes for block.
*/
const Cart = ( { attributes }: CartProps ): JSX.Element => {
const {
isShippingCalculatorEnabled,
hasDarkControls,
showRateAfterTaxName,
} = attributes;
const {
cartItems,
cartFees,
cartTotals,
cartIsLoading,
cartItemsCount,
cartItemErrors,
cartNeedsPayment,
cartNeedsShipping,
} = useStoreCart();
const {
applyCoupon,
removeCoupon,
isApplyingCoupon,
isRemovingCoupon,
appliedCoupons,
} = useStoreCartCoupons();
const { addErrorNotice } = useStoreNotices();
// Ensures any cart errors listed in the API response get shown.
useEffect( () => {
cartItemErrors.forEach( ( error ) => {
addErrorNotice( decodeEntities( error.message ), {
isDismissible: true,
id: error.code,
} );
} );
}, [ addErrorNotice, cartItemErrors ] );
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const cartClassName = classnames( 'wc-block-cart', {
'wc-block-cart--is-loading': cartIsLoading,
'has-dark-controls': hasDarkControls,
} );
// Prepare props to pass to the ExperimentalOrderMeta slot fill.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { extensions, receiveCart, ...cart } = useStoreCart();
const slotFillProps = {
extensions,
cart,
};
const discountsSlotFillProps = {
extensions,
cart,
};
return (
<>
<CartLineItemsTitle itemCount={ cartItemsCount } />
<SidebarLayout className={ cartClassName }>
<Main className="wc-block-cart__main">
<CartLineItemsTable
lineItems={ cartItems }
isLoading={ cartIsLoading }
/>
</Main>
<Sidebar className="wc-block-cart__sidebar">
<Title
headingLevel="2"
className="wc-block-cart__totals-title"
>
{ __( 'Cart totals', 'woo-gutenberg-products-block' ) }
</Title>
<TotalsWrapper>
<Subtotal
currency={ totalsCurrency }
values={ cartTotals }
/>
<TotalsFees
currency={ totalsCurrency }
cartFees={ cartFees }
/>
<TotalsDiscount
cartCoupons={ appliedCoupons }
currency={ totalsCurrency }
isRemovingCoupon={ isRemovingCoupon }
removeCoupon={ removeCoupon }
values={ cartTotals }
/>
</TotalsWrapper>
{ getSetting( 'couponsEnabled', true ) && (
<TotalsWrapper>
<TotalsCoupon
onSubmit={ applyCoupon }
isLoading={ isApplyingCoupon }
/>
</TotalsWrapper>
) }
<ExperimentalDiscountsMeta.Slot
{ ...discountsSlotFillProps }
/>
{ cartNeedsShipping && (
<TotalsWrapper>
<TotalsShipping
showCalculator={ isShippingCalculatorEnabled }
showRateSelector={ true }
values={ cartTotals }
currency={ totalsCurrency }
/>
</TotalsWrapper>
) }
{ ! getSetting( 'displayCartPricesIncludingTax', false ) &&
parseInt( cartTotals.total_tax, 10 ) > 0 && (
<TotalsWrapper>
<TotalsTaxes
showRateAfterTaxName={
showRateAfterTaxName
}
currency={ totalsCurrency }
values={ cartTotals }
/>
</TotalsWrapper>
) }
<TotalsWrapper>
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</TotalsWrapper>
<ExperimentalOrderMeta.Slot { ...slotFillProps } />
<div className="wc-block-cart__payment-options">
{ cartNeedsPayment && <CartExpressPayment /> }
<CheckoutButton
link={ getSetting(
'page-' + attributes?.checkoutPageId,
false
) }
/>
</div>
</Sidebar>
</SidebarLayout>
</>
);
};
export default Cart;

View File

@ -0,0 +1,264 @@
.wc-block-cart {
.wc-block-components-shipping-calculator {
white-space: nowrap;
}
.wc-block-components-address-form {
.wc-block-components-text-input,
.wc-block-components-country-input,
.wc-block-components-state-input {
&:first-of-type {
margin-top: 0;
}
}
}
}
table.wc-block-cart-items,
table.wc-block-cart-items th,
table.wc-block-cart-items td {
// Override Storefront theme gray table background.
background: none !important;
// Remove borders on default themes.
border: 0;
margin: 0;
}
.editor-styles-wrapper table.wc-block-cart-items,
table.wc-block-cart-items {
width: 100%;
.wc-block-cart-items__header {
@include font-size(smaller);
text-transform: uppercase;
.wc-block-cart-items__header-image {
width: 100px;
}
.wc-block-cart-items__header-product {
visibility: hidden;
}
.wc-block-cart-items__header-total {
width: 100px;
text-align: right;
}
}
.wc-block-cart-items__row {
.wc-block-cart-item__image img {
width: 100%;
margin: 0;
}
.wc-block-cart-item__quantity {
.wc-block-cart-item__remove-link {
@include link-button;
@include font-size(smaller);
text-transform: none;
white-space: nowrap;
}
}
.wc-block-components-product-name {
display: block;
max-width: max-content;
}
.wc-block-cart-item__total {
@include font-size(regular);
text-align: right;
line-height: inherit;
}
.wc-block-components-product-metadata {
margin-bottom: 0.75em;
}
&.is-disabled {
opacity: 0.5;
pointer-events: none;
transition: opacity 200ms ease;
}
}
}
.wc-block-cart {
.wc-block-components-totals-taxes,
.wc-block-components-totals-footer-item {
margin: 0;
}
}
// Loading placeholder state.
.wc-block-cart--is-loading,
.wc-block-mini-cart__drawer.is-loading {
th span,
h2 span {
@include placeholder();
@include force-content();
min-width: 84px;
display: inline-block;
}
h2 span {
min-width: 33%;
}
.wc-block-components-product-price,
.wc-block-components-product-metadata,
.wc-block-components-quantity-selector {
@include placeholder();
}
.wc-block-components-product-name {
@include placeholder();
@include force-content();
min-width: 84px;
display: inline-block;
}
.wc-block-components-product-metadata {
margin-top: 0.25em;
min-width: 8em;
}
.wc-block-cart-item__remove-link {
visibility: hidden;
}
.wc-block-cart-item__image > a {
@include placeholder();
display: block;
}
.wc-block-components-product-price {
@include force-content();
max-width: 3em;
display: block;
margin-top: 0.25em;
}
.wc-block-cart__sidebar .components-card {
@include placeholder();
@include force-content();
min-height: 460px;
}
}
.wc-block-components-sidebar-layout.wc-block-cart--skeleton {
display: none;
}
.is-loading + .wc-block-components-sidebar-layout.wc-block-cart--skeleton {
display: flex;
}
.wc-block-cart-item__total-price-and-sale-badge-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
.wc-block-components-sale-badge {
margin-top: $gap-smallest;
}
}
.is-small,
.is-mobile {
.wc-block-cart-item__total {
.wc-block-components-sale-badge {
display: none;
}
}
}
.is-medium,
.is-small,
.is-mobile {
&.wc-block-cart {
.wc-block-components-sidebar {
.wc-block-cart__totals-title {
display: none;
}
}
}
table.wc-block-cart-items {
td {
padding: 0;
}
.wc-block-cart-items__header {
display: none;
}
.wc-block-cart-item__remove-link {
display: none;
}
.wc-block-cart-items__row {
@include with-translucent-border(0 0 1px);
display: grid;
grid-template-columns: 80px 132px;
padding: $gap 0;
.wc-block-cart-item__image {
grid-column-start: 1;
grid-row-start: 1;
padding-right: $gap;
}
.wc-block-cart-item__product {
grid-column-start: 2;
grid-column-end: 4;
grid-row-start: 1;
justify-self: stretch;
padding: 0 $gap $gap 0;
}
.wc-block-cart-item__quantity {
grid-column-start: 1;
grid-row-start: 2;
vertical-align: bottom;
padding-right: $gap;
align-self: end;
padding-top: $gap;
}
.wc-block-cart-item__total {
grid-row-start: 1;
.wc-block-components-formatted-money-amount {
display: inline-block;
}
}
}
}
}
.is-large.wc-block-cart {
.wc-block-cart-items {
@include with-translucent-border(0 0 1px);
th {
padding: 0.25rem $gap 0.25rem 0;
white-space: nowrap;
}
td {
@include with-translucent-border(1px 0 0);
padding: $gap 0 $gap $gap;
vertical-align: top;
}
th:last-child {
padding-right: 0;
}
td:last-child {
padding-right: $gap;
}
}
.wc-block-components-radio-control__input {
left: 0;
}
.wc-block-cart__totals-title {
@include text-heading();
@include font-size(smaller);
display: block;
font-weight: 600;
padding: 0.25rem 0;
text-align: right;
text-transform: uppercase;
}
.wc-block-components-sidebar {
.wc-block-components-shipping-calculator,
.wc-block-components-shipping-rates-control__package:not(.wc-block-components-panel) {
padding-left: $gap;
padding-right: $gap;
}
}
.wc-block-cart__payment-options {
padding: $gap;
}
}

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/block-editor';
import { Icon, cart } from '@woocommerce/icons';
import classnames from 'classnames';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import edit from './edit';
import './style.scss';
import blockAttributes from './attributes';
/**
* Register and run the Cart block.
*/
const settings = {
title: __( 'Cart', 'woocommerce' ),
icon: {
src: <Icon srcElement={ cart } />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __( 'Shopping cart.', 'woocommerce' ),
supports: {
align: [ 'wide', 'full' ],
html: false,
multiple: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: blockAttributes,
edit,
// Save the props to post content.
save( { attributes } ) {
return (
<div className={ classnames( 'is-loading', attributes.className ) }>
<InnerBlocks.Content />
</div>
);
},
};
registerFeaturePluginBlockType( 'woocommerce/cart', settings );

View File

@ -0,0 +1,7 @@
.wp-block-woocommerce-cart.is-loading {
display: none;
}
.wp-block-woocommerce-cart {
margin-bottom: 3em;
}

View File

@ -0,0 +1,165 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
import { previewCart } from '@woocommerce/resource-previews';
import { dispatch } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import { default as fetchMock } from 'jest-fetch-mock';
/**
* Internal dependencies
*/
import Block from '../block';
import { defaultCartState } from '../../../../data/default-states';
import { allSettings } from '../../../../settings/shared/settings-init';
const CartBlock = ( props ) => (
<SlotFillProvider>
<Block { ...props } />
</SlotFillProvider>
);
describe( 'Testing cart', () => {
beforeEach( async () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
await dispatch( storeKey ).invalidateResolutionForStore();
await dispatch( storeKey ).receiveCart( defaultCartState.cartData );
} );
afterEach( () => {
fetchMock.resetMocks();
} );
it( 'renders cart if there are items in the cart', async () => {
render(
<CartBlock
emptyCart={ null }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect(
screen.getByText( /Proceed to Checkout/i )
).toBeInTheDocument();
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
// ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."]
expect( console ).toHaveWarned();
} );
it( 'Contains a Taxes section if Core options are set to show it', async () => {
allSettings.displayCartPricesIncludingTax = false;
// The criteria for showing the Taxes section is:
// Display prices during basket and checkout: 'Excluding tax'.
const { container } = render(
<CartBlock
emptyCart={ null }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( container ).toMatchSnapshot();
} );
it( 'Shows individual tax lines if the store is set to do so', async () => {
allSettings.displayCartPricesIncludingTax = false;
allSettings.displayItemizedTaxes = true;
// The criteria for showing the lines in the Taxes section is:
// Display prices during basket and checkout: 'Excluding tax'.
// Display tax totals: 'Itemized';
const { container } = render(
<CartBlock
emptyCart={ null }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( container ).toMatchSnapshot();
} );
it( 'Shows rate percentages after tax lines if the block is set to do so', async () => {
allSettings.displayCartPricesIncludingTax = false;
allSettings.displayItemizedTaxes = true;
// The criteria for showing the lines in the Taxes section is:
// Display prices during basket and checkout: 'Excluding tax'.
// Display tax totals: 'Itemized';
const { container } = render(
<CartBlock
emptyCart={ null }
attributes={ {
showRateAfterTaxName: true,
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( container ).toMatchSnapshot();
} );
it( 'renders empty cart if there are no items in the cart', async () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
return Promise.resolve(
JSON.stringify( defaultCartState.cartData )
);
}
return Promise.resolve( '' );
} );
render(
<CartBlock
emptyCart={ '<div>Empty Cart</div>' }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( screen.getByText( /Empty Cart/i ) ).toBeInTheDocument();
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
} );
it( 'renders correct cart line subtotal when currency has 0 decimals', async () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
const cart = {
...previewCart,
// Make it so there is only one item to simplify things.
items: [
{
...previewCart.items[ 0 ],
totals: {
...previewCart.items[ 0 ].totals,
// Change price format so there are no decimals.
currency_minor_unit: 0,
currency_prefix: '',
currency_suffix: '€',
line_subtotal: '16',
line_total: '18',
},
},
],
};
return Promise.resolve( JSON.stringify( cart ) );
}
} );
render( <CartBlock emptyCart={ null } attributes={ {} } /> );
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( screen.getAllByRole( 'cell' )[ 1 ] ).toHaveTextContent( '16€' );
} );
} );

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
export const blockName = 'woocommerce/checkout';
export const blockAttributes = {
isPreview: {
type: 'boolean',
default: false,
save: false,
},
hasDarkControls: {
type: 'boolean',
default: getSetting( 'hasDarkEditorStyleSupport', false ),
},
showCompanyField: {
type: 'boolean',
default: false,
},
requireCompanyField: {
type: 'boolean',
default: false,
},
allowCreateAccount: {
type: 'boolean',
default: false,
},
showApartmentField: {
type: 'boolean',
default: true,
},
showPhoneField: {
type: 'boolean',
default: true,
},
requirePhoneField: {
type: 'boolean',
default: false,
},
// Deprecated - here for v1 migration support
showOrderNotes: {
type: 'boolean',
default: true,
},
showPolicyLinks: {
type: 'boolean',
default: true,
},
showReturnToCart: {
type: 'boolean',
default: true,
},
cartPageId: {
type: 'number',
default: 0,
},
showRateAfterTaxName: {
type: 'boolean',
default: getSetting( 'displayCartPricesIncludingTax', false ),
},
};

View File

@ -0,0 +1,191 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { createInterpolateElement, useEffect } from '@wordpress/element';
import { useStoreCart, useStoreNotices } from '@woocommerce/base-context/hooks';
import {
useCheckoutContext,
useValidationContext,
ValidationContextProvider,
StoreNoticesProvider,
CheckoutProvider,
} from '@woocommerce/base-context';
import { StoreSnackbarNoticesProvider } from '@woocommerce/base-context/providers';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
/**
* Internal dependencies
*/
import './styles/style.scss';
import EmptyCart from './empty-cart';
import CheckoutOrderError from './checkout-order-error';
import { LOGIN_TO_CHECKOUT_URL, isLoginRequired, reloadPage } from './utils';
import type { Attributes } from './types';
import { CheckoutBlockContext } from './context';
const LoginPrompt = () => {
return (
<>
{ __(
'You must be logged in to checkout. ',
'woo-gutenberg-products-block'
) }
<a href={ LOGIN_TO_CHECKOUT_URL }>
{ __(
'Click here to log in.',
'woo-gutenberg-products-block'
) }
</a>
</>
);
};
const Checkout = ( {
attributes,
children,
}: {
attributes: Attributes;
children: React.ReactChildren;
} ): JSX.Element => {
const { hasOrder, customerId } = useCheckoutContext();
const { cartItems, cartIsLoading } = useStoreCart();
const {
allowCreateAccount,
showCompanyField,
requireCompanyField,
showApartmentField,
showPhoneField,
requirePhoneField,
} = attributes;
if ( ! cartIsLoading && cartItems.length === 0 ) {
return <EmptyCart />;
}
if ( ! hasOrder ) {
return <CheckoutOrderError />;
}
if (
isLoginRequired( customerId ) &&
allowCreateAccount &&
getSetting( 'checkoutAllowsSignup', false )
) {
<LoginPrompt />;
}
return (
<CheckoutBlockContext.Provider
value={ {
allowCreateAccount,
showCompanyField,
requireCompanyField,
showApartmentField,
showPhoneField,
requirePhoneField,
} }
>
{ children }
</CheckoutBlockContext.Provider>
);
};
const ScrollOnError = ( {
scrollToTop,
}: {
scrollToTop: ( props: Record< string, unknown > ) => void;
} ): null => {
const { hasNoticesOfType } = useStoreNotices();
const {
hasError: checkoutHasError,
isIdle: checkoutIsIdle,
} = useCheckoutContext();
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const hasErrorsToDisplay =
checkoutIsIdle &&
checkoutHasError &&
( hasValidationErrors || hasNoticesOfType( 'default' ) );
useEffect( () => {
let scrollToTopTimeout: number;
if ( hasErrorsToDisplay ) {
showAllValidationErrors();
// Scroll after a short timeout to allow a re-render. This will allow focusableSelector to match updated components.
scrollToTopTimeout = window.setTimeout( () => {
scrollToTop( {
focusableSelector: 'input:invalid, .has-error input',
} );
}, 50 );
}
return () => {
clearTimeout( scrollToTopTimeout );
};
}, [ hasErrorsToDisplay, scrollToTop, showAllValidationErrors ] );
return null;
};
const Block = ( {
attributes,
children,
scrollToTop,
}: {
attributes: Attributes;
children: React.ReactChildren;
scrollToTop: ( props: Record< string, unknown > ) => void;
} ): JSX.Element => (
<BlockErrorBoundary
header={ __( 'Something went wrong…', 'woo-gutenberg-products-block' ) }
text={ createInterpolateElement(
__(
'The checkout has encountered an unexpected error. <button>Try reloading the page</button>. If the error persists, please get in touch with us so we can assist.',
'woo-gutenberg-products-block'
),
{
button: (
<button
className="wc-block-link-button"
onClick={ reloadPage }
/>
),
}
) }
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<StoreSnackbarNoticesProvider context="wc/checkout">
<StoreNoticesProvider context="wc/checkout">
<ValidationContextProvider>
{ /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ }
<SlotFillProvider>
<CheckoutProvider>
<SidebarLayout
className={ classnames( 'wc-block-checkout', {
'has-dark-controls':
attributes.hasDarkControls,
} ) }
>
<Checkout attributes={ attributes }>
{ children }
</Checkout>
<ScrollOnError scrollToTop={ scrollToTop } />
</SidebarLayout>
</CheckoutProvider>
</SlotFillProvider>
</ValidationContextProvider>
</StoreNoticesProvider>
</StoreSnackbarNoticesProvider>
</BlockErrorBoundary>
);
export default withScrollToTop( Block );

View File

@ -0,0 +1,8 @@
export const PRODUCT_OUT_OF_STOCK = 'woocommerce_product_out_of_stock';
export const PRODUCT_NOT_PURCHASABLE =
'woocommerce_rest_cart_product_is_not_purchasable';
export const PRODUCT_NOT_ENOUGH_STOCK =
'woocommerce_rest_cart_product_no_stock';
export const PRODUCT_SOLD_INDIVIDUALLY =
'woocommerce_rest_cart_product_sold_individually';
export const GENERIC_CART_ITEM_ERROR = 'woocommerce_rest_cart_item_error';

View File

@ -0,0 +1,137 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CART_URL } from '@woocommerce/block-settings';
import { Icon, removeCart } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import './style.scss';
import {
PRODUCT_OUT_OF_STOCK,
PRODUCT_NOT_PURCHASABLE,
PRODUCT_NOT_ENOUGH_STOCK,
PRODUCT_SOLD_INDIVIDUALLY,
GENERIC_CART_ITEM_ERROR,
} from './constants';
const cartItemErrorCodes = [
PRODUCT_OUT_OF_STOCK,
PRODUCT_NOT_PURCHASABLE,
PRODUCT_NOT_ENOUGH_STOCK,
PRODUCT_SOLD_INDIVIDUALLY,
GENERIC_CART_ITEM_ERROR,
];
/**
* When an order was not created for the checkout, for example, when an item
* was out of stock, this component will be shown instead of the checkout form.
*
* The error message is derived by the hydrated API request passed to the
* checkout block.
*/
const CheckoutOrderError = () => {
const preloadedApiRequests = getSetting( 'preloadedApiRequests', {} );
const checkoutData = {
code: '',
message: '',
...( preloadedApiRequests[ '/wc/store/checkout' ]?.body || {} ),
};
const errorData = {
code: checkoutData.code || 'unknown',
message:
decodeEntities( checkoutData.message ) ||
__(
'There was a problem checking out. Please try again. If the problem persists, please get in touch with us so we can assist.',
'woocommerce'
),
};
return (
<div className="wc-block-checkout-error">
<Icon
className="wc-block-checkout-error__image"
alt=""
srcElement={ removeCart }
size={ 100 }
/>
<ErrorTitle errorData={ errorData } />
<ErrorMessage errorData={ errorData } />
<ErrorButton errorData={ errorData } />
</div>
);
};
/**
* Get the error message to display.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.errorData Object containing code and message.
*/
const ErrorTitle = ( { errorData } ) => {
let heading = __( 'Checkout error', 'woocommerce' );
if ( cartItemErrorCodes.includes( errorData.code ) ) {
heading = __(
'There is a problem with your cart',
'woocommerce'
);
}
return (
<strong className="wc-block-checkout-error_title">{ heading }</strong>
);
};
/**
* Get the error message to display.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.errorData Object containing code and message.
*/
const ErrorMessage = ( { errorData } ) => {
let message = errorData.message;
if ( cartItemErrorCodes.includes( errorData.code ) ) {
message =
message +
' ' +
__(
'Please edit your cart and try again.',
'woocommerce'
);
}
return <p className="wc-block-checkout-error__description">{ message }</p>;
};
/**
* Get the CTA button to display.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.errorData Object containing code and message.
*/
const ErrorButton = ( { errorData } ) => {
let buttonText = __( 'Retry', 'woocommerce' );
let buttonUrl = 'javascript:window.location.reload(true)';
if ( cartItemErrorCodes.includes( errorData.code ) ) {
buttonText = __( 'Edit your cart', 'woocommerce' );
buttonUrl = CART_URL;
}
return (
<span className="wp-block-button">
<a href={ buttonUrl } className="wp-block-button__link">
{ buttonText }
</a>
</span>
);
};
export default CheckoutOrderError;

View File

@ -0,0 +1,21 @@
.wc-block-checkout-error {
padding: $gap-largest;
text-align: center;
width: 100%;
.wc-block-checkout-error__image {
max-width: 150px;
margin: 0 auto 1em;
display: block;
color: inherit;
}
.wc-block-checkout-error__title {
display: block;
margin: 0;
font-weight: bold;
}
.wc-block-checkout-error__description {
display: block;
margin: 0.25em 0 1em 0;
}
}

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
export const Columns = ( {
children,
...props
}: {
children?: React.ReactNode;
} ): JSX.Element => {
const blockProps = useBlockProps( props );
return <div { ...blockProps }>{ children }</div>;
};

View File

@ -0,0 +1 @@
export * from './columns-block';

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Context consumed by inner blocks.
*/
export type CheckoutBlockContextProps = {
allowCreateAccount: boolean;
showCompanyField: boolean;
showApartmentField: boolean;
showPhoneField: boolean;
requireCompanyField: boolean;
requirePhoneField: boolean;
};
export type CheckoutBlockControlsContextProps = {
addressFieldControls: () => JSX.Element | null;
accountControls: () => JSX.Element | null;
};
export const CheckoutBlockContext = createContext< CheckoutBlockContextProps >(
{
allowCreateAccount: false,
showCompanyField: false,
showApartmentField: false,
showPhoneField: false,
requireCompanyField: false,
requirePhoneField: false,
}
);
export const CheckoutBlockControlsContext = createContext<
CheckoutBlockControlsContextProps
>( {
addressFieldControls: () => null,
accountControls: () => null,
} );
export const useCheckoutBlockContext = (): CheckoutBlockContextProps => {
return useContext( CheckoutBlockContext );
};
export const useCheckoutBlockControlsContext = (): CheckoutBlockControlsContextProps => {
return useContext( CheckoutBlockControlsContext );
};

View File

@ -0,0 +1,322 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import {
InnerBlocks,
useBlockProps,
InspectorControls,
} from '@wordpress/block-editor';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import {
CheckoutProvider,
EditorProvider,
useEditorContext,
} from '@woocommerce/base-context';
import {
previewCart,
previewSavedPaymentMethods,
} from '@woocommerce/resource-previews';
import {
PanelBody,
ToggleControl,
CheckboxControl,
Notice,
} from '@wordpress/components';
import { CartCheckoutFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt';
import { CHECKOUT_PAGE_ID } from '@woocommerce/block-settings';
import { createInterpolateElement } from '@wordpress/element';
import { getAdminLink } from '@woocommerce/settings';
import { CartCheckoutCompatibilityNotice } from '@woocommerce/editor-components/compatibility-notices';
/**
* Internal dependencies
*/
import './styles/editor.scss';
import { Columns } from './columns';
import { addClassToBody, useBlockPropsWithLocking } from './hacks';
import { CheckoutBlockContext, CheckoutBlockControlsContext } from './context';
import type { Attributes } from './types';
// This is adds a class to body to signal if the selected block is locked
addClassToBody();
// Array of allowed block names.
const ALLOWED_BLOCKS: string[] = [
'woocommerce/checkout-fields-block',
'woocommerce/checkout-totals-block',
];
const BlockSettings = ( {
attributes,
setAttributes,
}: {
attributes: Attributes;
setAttributes: ( attributes: Record< string, unknown > ) => undefined;
} ): JSX.Element => {
const { hasDarkControls } = attributes;
const { currentPostId } = useEditorContext();
return (
<InspectorControls>
{ currentPostId !== CHECKOUT_PAGE_ID && (
<Notice
className="wc-block-checkout__page-notice"
isDismissible={ false }
status="warning"
>
{ createInterpolateElement(
__(
'If you would like to use this block as your default checkout you must update your <a>page settings in WooCommerce</a>.',
'woo-gutenberg-products-block'
),
{
a: (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={ getAdminLink(
'admin.php?page=wc-settings&tab=advanced'
) }
target="_blank"
rel="noopener noreferrer"
/>
),
}
) }
</Notice>
) }
<PanelBody title={ __( 'Style', 'woo-gutenberg-products-block' ) }>
<ToggleControl
label={ __(
'Dark mode inputs',
'woo-gutenberg-products-block'
) }
help={ __(
'Inputs styled specifically for use on dark background colors.',
'woo-gutenberg-products-block'
) }
checked={ hasDarkControls }
onChange={ () =>
setAttributes( {
hasDarkControls: ! hasDarkControls,
} )
}
/>
</PanelBody>
<CartCheckoutFeedbackPrompt />
</InspectorControls>
);
};
export const Edit = ( {
attributes,
setAttributes,
}: {
attributes: Attributes;
setAttributes: ( attributes: Record< string, unknown > ) => undefined;
} ): JSX.Element => {
const {
allowCreateAccount,
showCompanyField,
requireCompanyField,
showApartmentField,
showPhoneField,
requirePhoneField,
showOrderNotes,
showPolicyLinks,
showReturnToCart,
showRateAfterTaxName,
cartPageId,
} = attributes;
const defaultInnerBlocksTemplate = [
[
'woocommerce/checkout-fields-block',
{},
[
[ 'woocommerce/checkout-express-payment-block', {}, [] ],
[ 'woocommerce/checkout-contact-information-block', {}, [] ],
[ 'woocommerce/checkout-shipping-address-block', {}, [] ],
[ 'woocommerce/checkout-billing-address-block', {}, [] ],
[ 'woocommerce/checkout-shipping-methods-block', {}, [] ],
[ 'woocommerce/checkout-payment-block', {}, [] ],
showOrderNotes
? [ 'woocommerce/checkout-order-note-block', {}, [] ]
: false,
showPolicyLinks
? [ 'woocommerce/checkout-terms-block', {}, [] ]
: false,
[
'woocommerce/checkout-actions-block',
{
showReturnToCart,
cartPageId,
},
[],
],
].filter( Boolean ),
],
[
'woocommerce/checkout-totals-block',
{},
[
[
'woocommerce/checkout-order-summary-block',
{
showRateAfterTaxName,
},
[],
],
],
],
];
const toggleAttribute = ( key: keyof Attributes ): void => {
const newAttributes = {} as Partial< Attributes >;
newAttributes[ key ] = ! ( attributes[ key ] as boolean );
setAttributes( newAttributes );
};
const accountControls = (): JSX.Element => (
<InspectorControls>
<PanelBody
title={ __(
'Account options',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Allow shoppers to sign up for a user account during checkout',
'woo-gutenberg-products-block'
) }
checked={ allowCreateAccount }
onChange={ () =>
setAttributes( {
allowCreateAccount: ! allowCreateAccount,
} )
}
/>
</PanelBody>
</InspectorControls>
);
const addressFieldControls = (): JSX.Element => (
<InspectorControls>
<PanelBody
title={ __( 'Address Fields', 'woo-gutenberg-products-block' ) }
>
<p className="wc-block-checkout__controls-text">
{ __(
'Show or hide fields in the checkout address forms.',
'woo-gutenberg-products-block'
) }
</p>
<ToggleControl
label={ __( 'Company', 'woo-gutenberg-products-block' ) }
checked={ showCompanyField }
onChange={ () => toggleAttribute( 'showCompanyField' ) }
/>
{ showCompanyField && (
<CheckboxControl
label={ __(
'Require company name?',
'woo-gutenberg-products-block'
) }
checked={ requireCompanyField }
onChange={ () =>
toggleAttribute( 'requireCompanyField' )
}
className="components-base-control--nested"
/>
) }
<ToggleControl
label={ __(
'Apartment, suite, etc.',
'woo-gutenberg-products-block'
) }
checked={ showApartmentField }
onChange={ () => toggleAttribute( 'showApartmentField' ) }
/>
<ToggleControl
label={ __( 'Phone', 'woo-gutenberg-products-block' ) }
checked={ showPhoneField }
onChange={ () => toggleAttribute( 'showPhoneField' ) }
/>
{ showPhoneField && (
<CheckboxControl
label={ __(
'Require phone number?',
'woo-gutenberg-products-block'
) }
checked={ requirePhoneField }
onChange={ () =>
toggleAttribute( 'requirePhoneField' )
}
className="components-base-control--nested"
/>
) }
</PanelBody>
</InspectorControls>
);
const blockProps = useBlockPropsWithLocking();
return (
<div { ...blockProps }>
<EditorProvider
previewData={ { previewCart, previewSavedPaymentMethods } }
>
<BlockSettings
attributes={ attributes }
setAttributes={ setAttributes }
/>
<CheckoutProvider>
<Columns>
<SidebarLayout
className={ classnames( 'wc-block-checkout', {
'has-dark-controls': attributes.hasDarkControls,
} ) }
>
<CheckoutBlockControlsContext.Provider
value={ {
addressFieldControls,
accountControls,
} }
>
<CheckoutBlockContext.Provider
value={ {
allowCreateAccount,
showCompanyField,
requireCompanyField,
showApartmentField,
showPhoneField,
requirePhoneField,
} }
>
<InnerBlocks
allowedBlocks={ ALLOWED_BLOCKS }
template={ defaultInnerBlocksTemplate }
templateLock="insert"
/>
</CheckoutBlockContext.Provider>
</CheckoutBlockControlsContext.Provider>
</SidebarLayout>
</Columns>
</CheckoutProvider>
</EditorProvider>
<CartCheckoutCompatibilityNotice blockName="checkout" />
</div>
);
};
export const Save = (): JSX.Element => {
return (
<div
{ ...useBlockProps.save( {
className: 'wc-block-checkout is-loading',
} ) }
>
<InnerBlocks.Content />
</div>
);
};

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { getBlockTypes } from '@wordpress/blocks';
// List of core block types to allow in inner block areas.
const coreBlockTypes = [ 'core/paragraph', 'core/image', 'core/separator' ];
/**
* Gets a list of allowed blocks types under a specific parent block type.
*/
export const getAllowedBlocks = ( block: string ): string[] => [
...getBlockTypes()
.filter( ( blockType ) =>
( blockType?.parent || [] ).includes( block )
)
.map( ( { name } ) => name ),
...coreBlockTypes,
];

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { SHOP_URL } from '@woocommerce/block-settings';
import { Icon, cart } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import './style.scss';
const EmptyCart = () => {
return (
<div className="wc-block-checkout-empty">
<Icon
className="wc-block-checkout-empty__image"
alt=""
srcElement={ cart }
size={ 100 }
/>
<strong className="wc-block-checkout-empty__title">
{ __( 'Your cart is empty!', 'woocommerce' ) }
</strong>
<p className="wc-block-checkout-empty__description">
{ __(
"Checkout is not available whilst your cart is empty—please take a look through our store and come back when you're ready to place an order.",
'woocommerce'
) }
</p>
{ SHOP_URL && (
<span className="wp-block-button">
<a href={ SHOP_URL } className="wp-block-button__link">
{ __( 'Browse store', 'woocommerce' ) }
</a>
</span>
) }
</div>
);
};
export default EmptyCart;

View File

@ -0,0 +1,21 @@
.wc-block-checkout-empty {
padding: $gap-largest;
text-align: center;
width: 100%;
.wc-block-checkout-empty__image {
max-width: 150px;
margin: 0 auto 1em;
display: block;
color: inherit;
}
.wc-block-checkout-empty__title {
display: block;
margin: 0;
font-weight: bold;
}
.wc-block-checkout-empty__description {
display: block;
margin: 0.25em 0 1em 0;
}
}

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import './editor.scss';
import { useForcedLayout } from '../use-forced-layout';
import { getAllowedBlocks } from '../editor-utils';
export const AdditionalFields = ( {
block,
}: {
// Name of the parent block.
block: string;
} ): JSX.Element => {
const { 'data-block': clientId } = useBlockProps();
const allowedBlocks = getAllowedBlocks( block );
useForcedLayout( {
clientId,
template: allowedBlocks,
} );
return (
<div className="wc-block-checkout__additional_fields">
<InnerBlocks allowedBlocks={ allowedBlocks } />
</div>
);
};
export const AdditionalFieldsContent = (): JSX.Element => (
<InnerBlocks.Content />
);

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
const attributes = ( {
defaultTitle = __( 'Step', 'woo-gutenberg-products-block' ),
defaultDescription = __(
'Step description text.',
'woo-gutenberg-products-block'
),
defaultShowStepNumber = true,
}: {
defaultTitle: string;
defaultDescription: string;
defaultShowStepNumber?: boolean;
} ): Record< string, Record< string, unknown > > => ( {
title: {
type: 'string',
default: defaultTitle,
},
description: {
type: 'string',
default: defaultDescription,
},
showStepNumber: {
type: 'boolean',
default: defaultShowStepNumber,
},
} );
export default attributes;

View File

@ -0,0 +1,12 @@
.wc-block-checkout__additional_fields {
margin: 1.5em 0 -1.5em;
}
.wc-block-components-checkout-step__description-placeholder {
opacity: 0.5;
}
.wc-block-components-checkout-step__title {
display: flex;
width: 100%;
}

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import {
PlainText,
InspectorControls,
useBlockProps,
} from '@wordpress/block-editor';
import { PanelBody, ToggleControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import FormStepHeading from './form-step-heading';
export interface FormStepBlockProps {
attributes: { title: string; description: string; showStepNumber: boolean };
setAttributes: ( attributes: Record< string, unknown > ) => void;
className?: string;
children?: React.ReactNode;
lock?: { move: boolean; remove: boolean };
}
/**
* Form Step Block for use in the editor.
*/
export const FormStepBlock = ( {
attributes,
setAttributes,
className = '',
children,
}: FormStepBlockProps ): JSX.Element => {
const { title = '', description = '', showStepNumber = true } = attributes;
const blockProps = useBlockProps( {
className: classnames( 'wc-block-components-checkout-step', className, {
'wc-block-components-checkout-step--with-step-number': showStepNumber,
} ),
} );
return (
<div { ...blockProps }>
<InspectorControls>
<PanelBody
title={ __(
'Form Step Options',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Show step number',
'woo-gutenberg-products-block'
) }
checked={ showStepNumber }
onChange={ () =>
setAttributes( {
showStepNumber: ! showStepNumber,
} )
}
/>
</PanelBody>
</InspectorControls>
<FormStepHeading>
<PlainText
className={ '' }
value={ title }
onChange={ ( value ) => setAttributes( { title: value } ) }
/>
</FormStepHeading>
<div className="wc-block-components-checkout-step__container">
<p className="wc-block-components-checkout-step__description">
<PlainText
className={
! description
? 'wc-block-components-checkout-step__description-placeholder'
: ''
}
value={ description }
placeholder={ __(
'Optional text for this form step.',
'woo-gutenberg-products-block'
) }
onChange={ ( value ) =>
setAttributes( {
description: value,
} )
}
/>
</p>
<div className="wc-block-components-checkout-step__content">
{ children }
</div>
</div>
</div>
);
};

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