initial commit
This commit is contained in:
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
useCollection,
|
||||
useQueryStateByKey,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { renderRemovableListItem } from './utils';
|
||||
import { removeAttributeFilterBySlug } from '../../utils/attributes-query';
|
||||
|
||||
/**
|
||||
* Component that renders active attribute (terms) filters.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {Object} props.attributeObject The attribute object.
|
||||
* @param {Array} props.slugs The slugs for attributes.
|
||||
* @param {string} props.operator The operator for the filter.
|
||||
* @param {string} props.displayStyle The style used for displaying the filters.
|
||||
*/
|
||||
const ActiveAttributeFilters = ( {
|
||||
attributeObject = {},
|
||||
slugs = [],
|
||||
operator = 'in',
|
||||
displayStyle,
|
||||
} ) => {
|
||||
const { results, isLoading } = useCollection( {
|
||||
namespace: '/wc/store',
|
||||
resourceName: 'products/attributes/terms',
|
||||
resourceValues: [ attributeObject.id ],
|
||||
} );
|
||||
|
||||
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
|
||||
'attributes',
|
||||
[]
|
||||
);
|
||||
|
||||
if ( isLoading ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attributeLabel = attributeObject.label;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span className="wc-block-active-filters__list-item-type">
|
||||
{ attributeLabel }:
|
||||
</span>
|
||||
<ul>
|
||||
{ slugs.map( ( slug, index ) => {
|
||||
const termObject = results.find( ( term ) => {
|
||||
return term.slug === slug;
|
||||
} );
|
||||
|
||||
if ( ! termObject ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prefix = '';
|
||||
|
||||
if ( index > 0 && operator === 'and' ) {
|
||||
prefix = (
|
||||
<span className="wc-block-active-filters__list-item-operator">
|
||||
{ __( 'and', 'woocommerce' ) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return renderRemovableListItem( {
|
||||
type: attributeLabel,
|
||||
name: decodeEntities( termObject.name || slug ),
|
||||
prefix,
|
||||
removeCallback: () => {
|
||||
removeAttributeFilterBySlug(
|
||||
productAttributes,
|
||||
setProductAttributes,
|
||||
attributeObject,
|
||||
slug
|
||||
);
|
||||
},
|
||||
showLabel: false,
|
||||
displayStyle,
|
||||
} );
|
||||
} ) }
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveAttributeFilters;
|
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useQueryStateByKey } from '@woocommerce/base-context/hooks';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import { useMemo } from '@wordpress/element';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import Label from '@woocommerce/base-components/label';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import { getAttributeFromTaxonomy } from '../../utils/attributes';
|
||||
import { formatPriceRange, renderRemovableListItem } from './utils';
|
||||
import ActiveAttributeFilters from './active-attribute-filters';
|
||||
|
||||
/**
|
||||
* Component displaying active filters.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {Object} props.attributes Incoming attributes for the block.
|
||||
* @param {boolean} props.isEditor Whether or not in the editor context.
|
||||
*/
|
||||
const ActiveFiltersBlock = ( {
|
||||
attributes: blockAttributes,
|
||||
isEditor = false,
|
||||
} ) => {
|
||||
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
|
||||
'attributes',
|
||||
[]
|
||||
);
|
||||
const [ productStockStatus, setProductStockStatus ] = useQueryStateByKey(
|
||||
'stock_status',
|
||||
[]
|
||||
);
|
||||
const [ minPrice, setMinPrice ] = useQueryStateByKey( 'min_price' );
|
||||
const [ maxPrice, setMaxPrice ] = useQueryStateByKey( 'max_price' );
|
||||
|
||||
const STOCK_STATUS_OPTIONS = getSetting( 'stockStatusOptions', [] );
|
||||
const activeStockStatusFilters = useMemo( () => {
|
||||
if ( productStockStatus.length > 0 ) {
|
||||
return productStockStatus.map( ( slug ) => {
|
||||
return renderRemovableListItem( {
|
||||
type: __( 'Stock Status', 'woocommerce' ),
|
||||
name: STOCK_STATUS_OPTIONS[ slug ],
|
||||
removeCallback: () => {
|
||||
const newStatuses = productStockStatus.filter(
|
||||
( status ) => {
|
||||
return status !== slug;
|
||||
}
|
||||
);
|
||||
setProductStockStatus( newStatuses );
|
||||
},
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} );
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
STOCK_STATUS_OPTIONS,
|
||||
productStockStatus,
|
||||
setProductStockStatus,
|
||||
blockAttributes.displayStyle,
|
||||
] );
|
||||
|
||||
const activePriceFilters = useMemo( () => {
|
||||
if ( ! Number.isFinite( minPrice ) && ! Number.isFinite( maxPrice ) ) {
|
||||
return null;
|
||||
}
|
||||
return renderRemovableListItem( {
|
||||
type: __( 'Price', 'woocommerce' ),
|
||||
name: formatPriceRange( minPrice, maxPrice ),
|
||||
removeCallback: () => {
|
||||
setMinPrice( undefined );
|
||||
setMaxPrice( undefined );
|
||||
},
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} );
|
||||
}, [
|
||||
minPrice,
|
||||
maxPrice,
|
||||
blockAttributes.displayStyle,
|
||||
setMinPrice,
|
||||
setMaxPrice,
|
||||
] );
|
||||
|
||||
const activeAttributeFilters = useMemo( () => {
|
||||
return productAttributes.map( ( attribute ) => {
|
||||
const attributeObject = getAttributeFromTaxonomy(
|
||||
attribute.attribute
|
||||
);
|
||||
return (
|
||||
<ActiveAttributeFilters
|
||||
attributeObject={ attributeObject }
|
||||
displayStyle={ blockAttributes.displayStyle }
|
||||
slugs={ attribute.slug }
|
||||
key={ attribute.attribute }
|
||||
operator={ attribute.operator }
|
||||
/>
|
||||
);
|
||||
} );
|
||||
}, [ productAttributes, blockAttributes.displayStyle ] );
|
||||
|
||||
const hasFilters = () => {
|
||||
return (
|
||||
productAttributes.length > 0 ||
|
||||
productStockStatus.length > 0 ||
|
||||
Number.isFinite( minPrice ) ||
|
||||
Number.isFinite( maxPrice )
|
||||
);
|
||||
};
|
||||
|
||||
if ( ! hasFilters() && ! isEditor ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TagName = `h${ blockAttributes.headingLevel }`;
|
||||
const listClasses = classnames( 'wc-block-active-filters__list', {
|
||||
'wc-block-active-filters__list--chips':
|
||||
blockAttributes.displayStyle === 'chips',
|
||||
} );
|
||||
|
||||
return (
|
||||
<>
|
||||
{ ! isEditor && blockAttributes.heading && (
|
||||
<TagName className="wc-block-active-filters__title">
|
||||
{ blockAttributes.heading }
|
||||
</TagName>
|
||||
) }
|
||||
<div className="wc-block-active-filters">
|
||||
<ul className={ listClasses }>
|
||||
{ isEditor ? (
|
||||
<>
|
||||
{ renderRemovableListItem( {
|
||||
type: __(
|
||||
'Size',
|
||||
'woocommerce'
|
||||
),
|
||||
name: __(
|
||||
'Small',
|
||||
'woocommerce'
|
||||
),
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} ) }
|
||||
{ renderRemovableListItem( {
|
||||
type: __(
|
||||
'Color',
|
||||
'woocommerce'
|
||||
),
|
||||
name: __(
|
||||
'Blue',
|
||||
'woocommerce'
|
||||
),
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} ) }
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ activePriceFilters }
|
||||
{ activeStockStatusFilters }
|
||||
{ activeAttributeFilters }
|
||||
</>
|
||||
) }
|
||||
</ul>
|
||||
<button
|
||||
className="wc-block-active-filters__clear-all"
|
||||
onClick={ () => {
|
||||
setMinPrice( undefined );
|
||||
setMaxPrice( undefined );
|
||||
setProductAttributes( [] );
|
||||
setProductStockStatus( [] );
|
||||
} }
|
||||
>
|
||||
<Label
|
||||
label={ __(
|
||||
'Clear All',
|
||||
'woocommerce'
|
||||
) }
|
||||
screenReaderLabel={ __(
|
||||
'Clear All Filters',
|
||||
'woocommerce'
|
||||
) }
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveFiltersBlock.propTypes = {
|
||||
/**
|
||||
* The attributes for this block.
|
||||
*/
|
||||
attributes: PropTypes.object.isRequired,
|
||||
/**
|
||||
* Whether it's in the editor or frontend display.
|
||||
*/
|
||||
isEditor: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ActiveFiltersBlock;
|
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { InspectorControls } from '@wordpress/block-editor';
|
||||
import { Disabled, PanelBody, withSpokenMessages } from '@wordpress/components';
|
||||
import HeadingToolbar from '@woocommerce/editor-components/heading-toolbar';
|
||||
import BlockTitle from '@woocommerce/editor-components/block-title';
|
||||
import ToggleButtonControl from '@woocommerce/editor-components/toggle-button-control';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block.js';
|
||||
|
||||
const Edit = ( { attributes, setAttributes } ) => {
|
||||
const { className, displayStyle, heading, headingLevel } = attributes;
|
||||
|
||||
const getInspectorControls = () => {
|
||||
return (
|
||||
<InspectorControls key="inspector">
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Block Settings',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<ToggleButtonControl
|
||||
label={ __(
|
||||
'Display Style',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={ displayStyle }
|
||||
options={ [
|
||||
{
|
||||
label: __(
|
||||
'List',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'list',
|
||||
},
|
||||
{
|
||||
/* translators: "Chips" is a tag-like display style for chosen attributes. */
|
||||
label: __(
|
||||
'Chips',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'chips',
|
||||
},
|
||||
] }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( {
|
||||
displayStyle: value,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
<p>
|
||||
{ __(
|
||||
'Heading Level',
|
||||
'woocommerce'
|
||||
) }
|
||||
</p>
|
||||
<HeadingToolbar
|
||||
isCollapsed={ false }
|
||||
minLevel={ 2 }
|
||||
maxLevel={ 7 }
|
||||
selectedLevel={ headingLevel }
|
||||
onChange={ ( newLevel ) =>
|
||||
setAttributes( { headingLevel: newLevel } )
|
||||
}
|
||||
/>
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={ className }>
|
||||
{ getInspectorControls() }
|
||||
<BlockTitle
|
||||
className="wc-block-active-filters__title"
|
||||
headingLevel={ headingLevel }
|
||||
heading={ heading }
|
||||
onChange={ ( value ) => setAttributes( { heading: value } ) }
|
||||
/>
|
||||
<Disabled>
|
||||
<Block attributes={ attributes } isEditor={ true } />
|
||||
</Disabled>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withSpokenMessages( Edit );
|
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { withRestApiHydration } from '@woocommerce/block-hocs';
|
||||
import { renderFrontend } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block.js';
|
||||
|
||||
const getProps = ( el ) => {
|
||||
return {
|
||||
attributes: {
|
||||
displayStyle: el.dataset.displayStyle,
|
||||
heading: el.dataset.heading,
|
||||
headingLevel: el.dataset.headingLevel || 3,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
renderFrontend( {
|
||||
selector: '.wp-block-woocommerce-active-filters',
|
||||
Block: withRestApiHydration( Block ),
|
||||
getProps,
|
||||
} );
|
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
import { Icon, toggle } from '@woocommerce/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import edit from './edit.js';
|
||||
|
||||
registerBlockType( 'woocommerce/active-filters', {
|
||||
title: __( 'Active Product Filters', 'woocommerce' ),
|
||||
icon: {
|
||||
src: <Icon srcElement={ toggle } />,
|
||||
foreground: '#96588a',
|
||||
},
|
||||
category: 'woocommerce',
|
||||
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
|
||||
description: __(
|
||||
'Show the currently active product filters. Works in combination with the All Products and filters blocks.',
|
||||
'woocommerce'
|
||||
),
|
||||
supports: {
|
||||
html: false,
|
||||
multiple: false,
|
||||
},
|
||||
example: {
|
||||
attributes: {},
|
||||
},
|
||||
attributes: {
|
||||
displayStyle: {
|
||||
type: 'string',
|
||||
default: 'list',
|
||||
},
|
||||
heading: {
|
||||
type: 'string',
|
||||
default: __( 'Active filters', 'woocommerce' ),
|
||||
},
|
||||
headingLevel: {
|
||||
type: 'number',
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
edit,
|
||||
// Save the props to post content.
|
||||
save( { attributes } ) {
|
||||
const { className, displayStyle, heading, headingLevel } = attributes;
|
||||
const data = {
|
||||
'data-display-style': displayStyle,
|
||||
'data-heading': heading,
|
||||
'data-heading-level': headingLevel,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={ classNames( 'is-loading', className ) }
|
||||
{ ...data }
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="wc-block-active-product-filters__placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} );
|
@ -0,0 +1,98 @@
|
||||
.wc-block-active-filters {
|
||||
margin-bottom: $gap-large;
|
||||
overflow: hidden;
|
||||
|
||||
.wc-block-active-filters__clear-all {
|
||||
@include font-size(regular);
|
||||
float: right;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
||||
&,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list {
|
||||
margin: 0 0 $gap-smallest;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
clear: both;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
clear: both;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.wc-block-active-filters__list-item-type {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-type {
|
||||
@include font-size(smaller);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: $gap 0 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-operator {
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-name {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 0 16px 0 0;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
margin: -8px 0 0 0;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list--chips {
|
||||
ul,
|
||||
li {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wc-block-components-chip {
|
||||
@include font-size(small);
|
||||
margin-top: em($gap-small*0.25);
|
||||
margin-bottom: em($gap-small*0.25);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { formatPrice } from '@woocommerce/price-format';
|
||||
import { RemovableChip } from '@woocommerce/base-components/chip';
|
||||
import Label from '@woocommerce/base-components/label';
|
||||
|
||||
/**
|
||||
* Format a min/max price range to display.
|
||||
*
|
||||
* @param {number} minPrice The min price, if set.
|
||||
* @param {number} maxPrice The max price, if set.
|
||||
*/
|
||||
export const formatPriceRange = ( minPrice, maxPrice ) => {
|
||||
if ( Number.isFinite( minPrice ) && Number.isFinite( maxPrice ) ) {
|
||||
return sprintf(
|
||||
/* translators: %1$s min price, %2$s max price */
|
||||
__( 'Between %1$s and %2$s', 'woocommerce' ),
|
||||
formatPrice( minPrice ),
|
||||
formatPrice( maxPrice )
|
||||
);
|
||||
}
|
||||
|
||||
if ( Number.isFinite( minPrice ) ) {
|
||||
return sprintf(
|
||||
/* translators: %s min price */
|
||||
__( 'From %s', 'woocommerce' ),
|
||||
formatPrice( minPrice )
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: %s max price */
|
||||
__( 'Up to %s', 'woocommerce' ),
|
||||
formatPrice( maxPrice )
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a removable item in the active filters block list.
|
||||
*
|
||||
* @param {Object} listItem The removable item to render.
|
||||
* @param {string} listItem.type Type string.
|
||||
* @param {string} listItem.name Name string.
|
||||
* @param {string} listItem.prefix Prefix shown before item name.
|
||||
* @param {Function} listItem.removeCallback Callback to remove item.
|
||||
* @param {string} listItem.displayStyle Whether it's a list or chips.
|
||||
* @param {boolean} [listItem.showLabel=true] Should the label be shown for
|
||||
* this item?
|
||||
*/
|
||||
export const renderRemovableListItem = ( {
|
||||
type,
|
||||
name,
|
||||
prefix,
|
||||
removeCallback = () => {},
|
||||
showLabel = true,
|
||||
displayStyle,
|
||||
} ) => {
|
||||
const prefixedName = prefix ? (
|
||||
<>
|
||||
{ prefix }
|
||||
|
||||
{ name }
|
||||
</>
|
||||
) : (
|
||||
name
|
||||
);
|
||||
const removeText = sprintf(
|
||||
/* translators: %s attribute value used in the filter. For example: yellow, green, small, large. */
|
||||
__( 'Remove %s filter', 'woocommerce' ),
|
||||
name
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className="wc-block-active-filters__list-item"
|
||||
key={ type + ':' + name }
|
||||
>
|
||||
{ showLabel && (
|
||||
<span className="wc-block-active-filters__list-item-type">
|
||||
{ type + ': ' }
|
||||
</span>
|
||||
) }
|
||||
{ displayStyle === 'chips' ? (
|
||||
<RemovableChip
|
||||
element="span"
|
||||
text={ prefixedName }
|
||||
onRemove={ removeCallback }
|
||||
radius="large"
|
||||
ariaLabel={ removeText }
|
||||
/>
|
||||
) : (
|
||||
<span className="wc-block-active-filters__list-item-name">
|
||||
{ prefixedName }
|
||||
<button
|
||||
className="wc-block-active-filters__list-item-remove"
|
||||
onClick={ removeCallback }
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ellipse
|
||||
cx="8"
|
||||
cy="8"
|
||||
rx="8"
|
||||
ry="8"
|
||||
transform="rotate(-180 8 8)"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="10.636"
|
||||
y="3.94983"
|
||||
width="2"
|
||||
height="9.9466"
|
||||
transform="rotate(45 10.636 3.94983)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="12.0503"
|
||||
y="11.0209"
|
||||
width="2"
|
||||
height="9.9466"
|
||||
transform="rotate(135 12.0503 11.0209)"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Label screenReaderLabel={ removeText } />
|
||||
</button>
|
||||
</span>
|
||||
) }
|
||||
</li>
|
||||
);
|
||||
};
|
@ -0,0 +1,381 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { speak } from '@wordpress/a11y';
|
||||
import { usePrevious, useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import {
|
||||
useCollection,
|
||||
useQueryStateByKey,
|
||||
useQueryStateByContext,
|
||||
useCollectionData,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { useCallback, useEffect, useState, useMemo } from '@wordpress/element';
|
||||
import CheckboxList from '@woocommerce/base-components/checkbox-list';
|
||||
import DropdownSelector from '@woocommerce/base-components/dropdown-selector';
|
||||
import Label from '@woocommerce/base-components/filter-element-label';
|
||||
import FilterSubmitButton from '@woocommerce/base-components/filter-submit-button';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getAttributeFromID } from '../../utils/attributes';
|
||||
import { updateAttributeFilter } from '../../utils/attributes-query';
|
||||
import { previewAttributeObject, previewOptions } from './preview';
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* Component displaying an attribute filter.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {Object} props.attributes Incoming block attributes.
|
||||
* @param {boolean} props.isEditor
|
||||
*/
|
||||
const AttributeFilterBlock = ( {
|
||||
attributes: blockAttributes,
|
||||
isEditor = false,
|
||||
} ) => {
|
||||
const attributeObject =
|
||||
blockAttributes.isPreview && ! blockAttributes.attributeId
|
||||
? previewAttributeObject
|
||||
: getAttributeFromID( blockAttributes.attributeId );
|
||||
|
||||
const [ checked, setChecked ] = useState( [] );
|
||||
const [ displayedOptions, setDisplayedOptions ] = useState(
|
||||
blockAttributes.isPreview && ! blockAttributes.attributeId
|
||||
? previewOptions
|
||||
: []
|
||||
);
|
||||
|
||||
const [ queryState ] = useQueryStateByContext();
|
||||
const [
|
||||
productAttributesQuery,
|
||||
setProductAttributesQuery,
|
||||
] = useQueryStateByKey( 'attributes', [] );
|
||||
|
||||
const {
|
||||
results: attributeTerms,
|
||||
isLoading: attributeTermsLoading,
|
||||
} = useCollection( {
|
||||
namespace: '/wc/store',
|
||||
resourceName: 'products/attributes/terms',
|
||||
resourceValues: [ attributeObject.id ],
|
||||
shouldSelect: blockAttributes.attributeId > 0,
|
||||
} );
|
||||
|
||||
const filterAvailableTerms =
|
||||
blockAttributes.displayStyle !== 'dropdown' &&
|
||||
blockAttributes.queryType === 'and';
|
||||
const {
|
||||
results: filteredCounts,
|
||||
isLoading: filteredCountsLoading,
|
||||
} = useCollectionData( {
|
||||
queryAttribute: {
|
||||
taxonomy: attributeObject.taxonomy,
|
||||
queryType: blockAttributes.queryType,
|
||||
},
|
||||
queryState: {
|
||||
...queryState,
|
||||
attributes: filterAvailableTerms ? queryState.attributes : null,
|
||||
},
|
||||
} );
|
||||
|
||||
/**
|
||||
* Get count data about a given term by ID.
|
||||
*/
|
||||
const getFilteredTerm = useCallback(
|
||||
( id ) => {
|
||||
if ( ! filteredCounts.attribute_counts ) {
|
||||
return null;
|
||||
}
|
||||
return filteredCounts.attribute_counts.find(
|
||||
( { term } ) => term === id
|
||||
);
|
||||
},
|
||||
[ filteredCounts ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Compare intersection of all terms and filtered counts to get a list of options to display.
|
||||
*/
|
||||
useEffect( () => {
|
||||
/**
|
||||
* Checks if a term slug is in the query state.
|
||||
*
|
||||
* @param {string} termSlug The term of the slug to check.
|
||||
*/
|
||||
const isTermInQueryState = ( termSlug ) => {
|
||||
if ( ! queryState?.attributes ) {
|
||||
return false;
|
||||
}
|
||||
return queryState.attributes.some(
|
||||
( { attribute, slug = [] } ) =>
|
||||
attribute === attributeObject.taxonomy &&
|
||||
slug.includes( termSlug )
|
||||
);
|
||||
};
|
||||
|
||||
if ( attributeTermsLoading || filteredCountsLoading ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOptions = attributeTerms
|
||||
.map( ( term ) => {
|
||||
const filteredTerm = getFilteredTerm( term.id );
|
||||
|
||||
// If there is no match this term doesn't match the current product collection - only render if checked.
|
||||
if (
|
||||
! filteredTerm &&
|
||||
! checked.includes( term.slug ) &&
|
||||
! isTermInQueryState( term.slug )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = filteredTerm ? filteredTerm.count : 0;
|
||||
|
||||
return {
|
||||
value: term.slug,
|
||||
name: decodeEntities( term.name ),
|
||||
label: (
|
||||
<Label
|
||||
name={ decodeEntities( term.name ) }
|
||||
count={ blockAttributes.showCounts ? count : null }
|
||||
/>
|
||||
),
|
||||
};
|
||||
} )
|
||||
.filter( Boolean );
|
||||
|
||||
setDisplayedOptions( newOptions );
|
||||
}, [
|
||||
attributeObject.taxonomy,
|
||||
attributeTerms,
|
||||
attributeTermsLoading,
|
||||
blockAttributes.showCounts,
|
||||
filteredCountsLoading,
|
||||
getFilteredTerm,
|
||||
checked,
|
||||
queryState.attributes,
|
||||
] );
|
||||
|
||||
const checkedQuery = useMemo( () => {
|
||||
return productAttributesQuery
|
||||
.filter(
|
||||
( { attribute } ) => attribute === attributeObject.taxonomy
|
||||
)
|
||||
.flatMap( ( { slug } ) => slug );
|
||||
}, [ productAttributesQuery, attributeObject.taxonomy ] );
|
||||
|
||||
const currentCheckedQuery = useShallowEqual( checkedQuery );
|
||||
const previousCheckedQuery = usePrevious( currentCheckedQuery );
|
||||
// Track ATTRIBUTES QUERY changes so the block reflects current filters.
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isShallowEqual( previousCheckedQuery, currentCheckedQuery ) && // checked query changed
|
||||
! isShallowEqual( checked, currentCheckedQuery ) // checked query doesn't match the UI
|
||||
) {
|
||||
setChecked( currentCheckedQuery );
|
||||
if ( ! blockAttributes.showFilterButton ) {
|
||||
onSubmit( currentCheckedQuery );
|
||||
}
|
||||
}
|
||||
}, [
|
||||
checked,
|
||||
currentCheckedQuery,
|
||||
previousCheckedQuery,
|
||||
onSubmit,
|
||||
blockAttributes.showFilterButton,
|
||||
] );
|
||||
|
||||
/**
|
||||
* Returns an array of term objects that have been chosen via the checkboxes.
|
||||
*/
|
||||
const getSelectedTerms = useCallback(
|
||||
( newChecked ) => {
|
||||
return attributeTerms.reduce( ( acc, term ) => {
|
||||
if ( newChecked.includes( term.slug ) ) {
|
||||
acc.push( term );
|
||||
}
|
||||
return acc;
|
||||
}, [] );
|
||||
},
|
||||
[ attributeTerms ]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
( isChecked ) => {
|
||||
if ( isEditor ) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAttributeFilter(
|
||||
productAttributesQuery,
|
||||
setProductAttributesQuery,
|
||||
attributeObject,
|
||||
getSelectedTerms( isChecked ),
|
||||
blockAttributes.queryType === 'or' ? 'in' : 'and'
|
||||
);
|
||||
},
|
||||
[
|
||||
isEditor,
|
||||
productAttributesQuery,
|
||||
setProductAttributesQuery,
|
||||
attributeObject,
|
||||
getSelectedTerms,
|
||||
blockAttributes.queryType,
|
||||
]
|
||||
);
|
||||
|
||||
const multiple =
|
||||
blockAttributes.displayStyle !== 'dropdown' ||
|
||||
blockAttributes.queryType === 'or';
|
||||
|
||||
/**
|
||||
* When a checkbox in the list changes, update state.
|
||||
*/
|
||||
const onChange = useCallback(
|
||||
( checkedValue ) => {
|
||||
const getFilterNameFromValue = ( filterValue ) => {
|
||||
const { name } = displayedOptions.find(
|
||||
( option ) => option.value === filterValue
|
||||
);
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
const announceFilterChange = ( { filterAdded, filterRemoved } ) => {
|
||||
const filterAddedName = filterAdded
|
||||
? getFilterNameFromValue( filterAdded )
|
||||
: null;
|
||||
const filterRemovedName = filterRemoved
|
||||
? getFilterNameFromValue( filterRemoved )
|
||||
: null;
|
||||
if ( filterAddedName && filterRemovedName ) {
|
||||
speak(
|
||||
sprintf(
|
||||
/* translators: %1$s and %2$s are attribute terms (for example: 'red', 'blue', 'large'...). */
|
||||
__(
|
||||
'%1$s filter replaced with %2$s.',
|
||||
'woocommerce'
|
||||
),
|
||||
filterAddedName,
|
||||
filterRemovedName
|
||||
)
|
||||
);
|
||||
} else if ( filterAddedName ) {
|
||||
speak(
|
||||
sprintf(
|
||||
/* translators: %s attribute term (for example: 'red', 'blue', 'large'...) */
|
||||
__(
|
||||
'%s filter added.',
|
||||
'woocommerce'
|
||||
),
|
||||
filterAddedName
|
||||
)
|
||||
);
|
||||
} else if ( filterRemovedName ) {
|
||||
speak(
|
||||
sprintf(
|
||||
/* translators: %s attribute term (for example: 'red', 'blue', 'large'...) */
|
||||
__(
|
||||
'%s filter removed.',
|
||||
'woocommerce'
|
||||
),
|
||||
filterRemovedName
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const previouslyChecked = checked.includes( checkedValue );
|
||||
let newChecked;
|
||||
|
||||
if ( ! multiple ) {
|
||||
newChecked = previouslyChecked ? [] : [ checkedValue ];
|
||||
const filterAdded = previouslyChecked ? null : checkedValue;
|
||||
const filterRemoved =
|
||||
checked.length === 1 ? checked[ 0 ] : null;
|
||||
announceFilterChange( { filterAdded, filterRemoved } );
|
||||
} else {
|
||||
newChecked = checked.filter(
|
||||
( value ) => value !== checkedValue
|
||||
);
|
||||
|
||||
if ( ! previouslyChecked ) {
|
||||
newChecked.push( checkedValue );
|
||||
newChecked.sort();
|
||||
announceFilterChange( { filterAdded: checkedValue } );
|
||||
} else {
|
||||
announceFilterChange( { filterRemoved: checkedValue } );
|
||||
}
|
||||
}
|
||||
|
||||
setChecked( newChecked );
|
||||
if ( ! blockAttributes.showFilterButton ) {
|
||||
onSubmit( newChecked );
|
||||
}
|
||||
},
|
||||
[
|
||||
checked,
|
||||
displayedOptions,
|
||||
multiple,
|
||||
onSubmit,
|
||||
blockAttributes.showFilterButton,
|
||||
]
|
||||
);
|
||||
|
||||
if ( displayedOptions.length === 0 && ! attributeTermsLoading ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TagName = `h${ blockAttributes.headingLevel }`;
|
||||
const isLoading = ! blockAttributes.isPreview && attributeTermsLoading;
|
||||
const isDisabled = ! blockAttributes.isPreview && filteredCountsLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ ! isEditor && blockAttributes.heading && (
|
||||
<TagName className="wc-block-attribute-filter__title">
|
||||
{ blockAttributes.heading }
|
||||
</TagName>
|
||||
) }
|
||||
<div
|
||||
className={ `wc-block-attribute-filter style-${ blockAttributes.displayStyle }` }
|
||||
>
|
||||
{ blockAttributes.displayStyle === 'dropdown' ? (
|
||||
<DropdownSelector
|
||||
attributeLabel={ attributeObject.label }
|
||||
checked={ checked }
|
||||
className={ 'wc-block-attribute-filter-dropdown' }
|
||||
inputLabel={ blockAttributes.heading }
|
||||
isLoading={ isLoading }
|
||||
multiple={ multiple }
|
||||
onChange={ onChange }
|
||||
options={ displayedOptions }
|
||||
/>
|
||||
) : (
|
||||
<CheckboxList
|
||||
className={ 'wc-block-attribute-filter-list' }
|
||||
options={ displayedOptions }
|
||||
checked={ checked }
|
||||
onChange={ onChange }
|
||||
isLoading={ isLoading }
|
||||
isDisabled={ isDisabled }
|
||||
/>
|
||||
) }
|
||||
{ blockAttributes.showFilterButton && (
|
||||
<FilterSubmitButton
|
||||
className="wc-block-attribute-filter__button"
|
||||
disabled={ isLoading || isDisabled }
|
||||
onClick={ () => onSubmit( checked ) }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributeFilterBlock;
|
@ -0,0 +1,405 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf, _n } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { InspectorControls, BlockControls } from '@wordpress/block-editor';
|
||||
import {
|
||||
Placeholder,
|
||||
Disabled,
|
||||
PanelBody,
|
||||
ToggleControl,
|
||||
Button,
|
||||
ToolbarGroup,
|
||||
withSpokenMessages,
|
||||
} from '@wordpress/components';
|
||||
import { Icon, server, external } from '@woocommerce/icons';
|
||||
import { SearchListControl } from '@woocommerce/components';
|
||||
import { mapValues, toArray, sortBy } from 'lodash';
|
||||
import { getAdminLink, getSetting } from '@woocommerce/settings';
|
||||
import HeadingToolbar from '@woocommerce/editor-components/heading-toolbar';
|
||||
import BlockTitle from '@woocommerce/editor-components/block-title';
|
||||
import ToggleButtonControl from '@woocommerce/editor-components/toggle-button-control';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block.js';
|
||||
import './editor.scss';
|
||||
|
||||
const ATTRIBUTES = getSetting( 'attributes', [] );
|
||||
|
||||
const Edit = ( { attributes, setAttributes, debouncedSpeak } ) => {
|
||||
const {
|
||||
attributeId,
|
||||
className,
|
||||
displayStyle,
|
||||
heading,
|
||||
headingLevel,
|
||||
isPreview,
|
||||
queryType,
|
||||
showCounts,
|
||||
showFilterButton,
|
||||
} = attributes;
|
||||
|
||||
const [ isEditing, setIsEditing ] = useState(
|
||||
! attributeId && ! isPreview
|
||||
);
|
||||
|
||||
const getBlockControls = () => {
|
||||
return (
|
||||
<BlockControls>
|
||||
<ToolbarGroup
|
||||
controls={ [
|
||||
{
|
||||
icon: 'edit',
|
||||
title: __( 'Edit', 'woocommerce' ),
|
||||
onClick: () => setIsEditing( ! isEditing ),
|
||||
isActive: isEditing,
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</BlockControls>
|
||||
);
|
||||
};
|
||||
|
||||
const getInspectorControls = () => {
|
||||
return (
|
||||
<InspectorControls key="inspector">
|
||||
<PanelBody
|
||||
title={ __( 'Content', 'woocommerce' ) }
|
||||
>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
'Product count',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={
|
||||
showCounts
|
||||
? __(
|
||||
'Product count is visible.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Product count is hidden.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ showCounts }
|
||||
onChange={ () =>
|
||||
setAttributes( {
|
||||
showCounts: ! showCounts,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
<p>
|
||||
{ __(
|
||||
'Heading Level',
|
||||
'woocommerce'
|
||||
) }
|
||||
</p>
|
||||
<HeadingToolbar
|
||||
isCollapsed={ false }
|
||||
minLevel={ 2 }
|
||||
maxLevel={ 7 }
|
||||
selectedLevel={ headingLevel }
|
||||
onChange={ ( newLevel ) =>
|
||||
setAttributes( { headingLevel: newLevel } )
|
||||
}
|
||||
/>
|
||||
</PanelBody>
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Block Settings',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<ToggleButtonControl
|
||||
label={ __(
|
||||
'Query Type',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={
|
||||
queryType === 'and'
|
||||
? __(
|
||||
'Products that have all of the selected attributes will be shown.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Products that have any of the selected attributes will be shown.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
value={ queryType }
|
||||
options={ [
|
||||
{
|
||||
label: __(
|
||||
'And',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'and',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Or',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'or',
|
||||
},
|
||||
] }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( {
|
||||
queryType: value,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
<ToggleButtonControl
|
||||
label={ __(
|
||||
'Display Style',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={ displayStyle }
|
||||
options={ [
|
||||
{
|
||||
label: __(
|
||||
'List',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'list',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Dropdown',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'dropdown',
|
||||
},
|
||||
] }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( {
|
||||
displayStyle: value,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
'Filter button',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={
|
||||
showFilterButton
|
||||
? __(
|
||||
'Products will only update when the button is pressed.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Products will update as options are selected.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ showFilterButton }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( {
|
||||
showFilterButton: value,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
</PanelBody>
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Filter Products by Attribute',
|
||||
'woocommerce'
|
||||
) }
|
||||
initialOpen={ false }
|
||||
>
|
||||
{ renderAttributeControl( { isCompact: true } ) }
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
);
|
||||
};
|
||||
|
||||
const noAttributesPlaceholder = () => (
|
||||
<Placeholder
|
||||
className="wc-block-attribute-filter"
|
||||
icon={ <Icon srcElement={ server } /> }
|
||||
label={ __(
|
||||
'Filter Products by Attribute',
|
||||
'woocommerce'
|
||||
) }
|
||||
instructions={ __(
|
||||
'Display a list of filters based on a chosen attribute.',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<p>
|
||||
{ __(
|
||||
"Attributes are needed for filtering your products. You haven't created any attributes yet.",
|
||||
'woocommerce'
|
||||
) }
|
||||
</p>
|
||||
<Button
|
||||
className="wc-block-attribute-filter__add-attribute-button"
|
||||
isSecondary
|
||||
href={ getAdminLink(
|
||||
'edit.php?post_type=product&page=product_attributes'
|
||||
) }
|
||||
>
|
||||
{ __( 'Add new attribute', 'woocommerce' ) +
|
||||
' ' }
|
||||
<Icon srcElement={ external } />
|
||||
</Button>
|
||||
<Button
|
||||
className="wc-block-attribute-filter__read_more_button"
|
||||
isTertiary
|
||||
href="https://docs.woocommerce.com/document/managing-product-taxonomies/"
|
||||
>
|
||||
{ __( 'Learn more', 'woocommerce' ) }
|
||||
</Button>
|
||||
</Placeholder>
|
||||
);
|
||||
|
||||
const onDone = () => {
|
||||
setIsEditing( false );
|
||||
debouncedSpeak(
|
||||
__(
|
||||
'Showing Filter Products by Attribute block preview.',
|
||||
'woocommerce'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onChange = ( selected ) => {
|
||||
if ( ! selected || ! selected.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedId = selected[ 0 ].id;
|
||||
const productAttribute = ATTRIBUTES.find(
|
||||
( attribute ) => attribute.attribute_id === selectedId.toString()
|
||||
);
|
||||
|
||||
if ( ! productAttribute || attributeId === selectedId ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeName = productAttribute.attribute_label;
|
||||
|
||||
setAttributes( {
|
||||
attributeId: selectedId,
|
||||
heading: sprintf(
|
||||
/* translators: %s attribute name. */
|
||||
__( 'Filter by %s', 'woocommerce' ),
|
||||
attributeName
|
||||
),
|
||||
} );
|
||||
};
|
||||
|
||||
const renderAttributeControl = ( { isCompact } ) => {
|
||||
const messages = {
|
||||
clear: __(
|
||||
'Clear selected attribute',
|
||||
'woocommerce'
|
||||
),
|
||||
list: __( 'Product Attributes', 'woocommerce' ),
|
||||
noItems: __(
|
||||
"Your store doesn't have any product attributes.",
|
||||
'woocommerce'
|
||||
),
|
||||
search: __(
|
||||
'Search for a product attribute:',
|
||||
'woocommerce'
|
||||
),
|
||||
selected: ( n ) =>
|
||||
sprintf(
|
||||
/* translators: %d is the number of attributes selected. */
|
||||
_n(
|
||||
'%d attribute selected',
|
||||
'%d attributes selected',
|
||||
n,
|
||||
'woocommerce'
|
||||
),
|
||||
n
|
||||
),
|
||||
updated: __(
|
||||
'Product attribute search results updated.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
|
||||
const list = sortBy(
|
||||
toArray(
|
||||
mapValues( ATTRIBUTES, ( item ) => {
|
||||
return {
|
||||
id: parseInt( item.attribute_id, 10 ),
|
||||
name: item.attribute_label,
|
||||
};
|
||||
} )
|
||||
),
|
||||
'name'
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchListControl
|
||||
className="woocommerce-product-attributes"
|
||||
list={ list }
|
||||
selected={ list.filter( ( { id } ) => id === attributeId ) }
|
||||
onChange={ onChange }
|
||||
messages={ messages }
|
||||
isSingle
|
||||
isCompact={ isCompact }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEditMode = () => {
|
||||
return (
|
||||
<Placeholder
|
||||
className="wc-block-attribute-filter"
|
||||
icon={ <Icon srcElement={ server } /> }
|
||||
label={ __(
|
||||
'Filter Products by Attribute',
|
||||
'woocommerce'
|
||||
) }
|
||||
instructions={ __(
|
||||
'Display a list of filters based on a chosen attribute.',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<div className="wc-block-attribute-filter__selection">
|
||||
{ renderAttributeControl( { isCompact: false } ) }
|
||||
<Button isPrimary onClick={ onDone }>
|
||||
{ __( 'Done', 'woocommerce' ) }
|
||||
</Button>
|
||||
</div>
|
||||
</Placeholder>
|
||||
);
|
||||
};
|
||||
|
||||
return Object.keys( ATTRIBUTES ).length === 0 ? (
|
||||
noAttributesPlaceholder()
|
||||
) : (
|
||||
<>
|
||||
{ getBlockControls() }
|
||||
{ getInspectorControls() }
|
||||
{ isEditing ? (
|
||||
renderEditMode()
|
||||
) : (
|
||||
<div className={ className }>
|
||||
<BlockTitle
|
||||
className="wc-block-attribute-filter__title"
|
||||
headingLevel={ headingLevel }
|
||||
heading={ heading }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( { heading: value } )
|
||||
}
|
||||
/>
|
||||
<Disabled>
|
||||
<Block attributes={ attributes } isEditor />
|
||||
</Disabled>
|
||||
</div>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withSpokenMessages( Edit );
|
@ -0,0 +1,36 @@
|
||||
.wc-block-attribute-filter {
|
||||
.components-placeholder__instructions {
|
||||
border-bottom: 1px solid #e0e2e6;
|
||||
width: 100%;
|
||||
padding-bottom: 1em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.components-placeholder__label svg {
|
||||
fill: currentColor;
|
||||
margin-right: 1ch;
|
||||
}
|
||||
.components-placeholder__fieldset {
|
||||
display: block; /* Disable flex box */
|
||||
}
|
||||
.woocommerce-search-list__search {
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.wc-block-attribute-filter__add-attribute-button {
|
||||
margin: 0 0 1em;
|
||||
vertical-align: middle;
|
||||
height: auto;
|
||||
padding: 0.5em 1em;
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
margin-left: 0.5ch;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.wc-block-attribute-filter__read_more_button {
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { withRestApiHydration } from '@woocommerce/block-hocs';
|
||||
import { renderFrontend } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block.js';
|
||||
|
||||
const getProps = ( el ) => {
|
||||
return {
|
||||
attributes: {
|
||||
attributeId: parseInt( el.dataset.attributeId || 0, 10 ),
|
||||
showCounts: el.dataset.showCounts === 'true',
|
||||
queryType: el.dataset.queryType,
|
||||
heading: el.dataset.heading,
|
||||
headingLevel: el.dataset.headingLevel || 3,
|
||||
displayStyle: el.dataset.displayStyle,
|
||||
showFilterButton: el.dataset.showFilterButton === 'true',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
renderFrontend( {
|
||||
selector: '.wp-block-woocommerce-attribute-filter',
|
||||
Block: withRestApiHydration( Block ),
|
||||
getProps,
|
||||
} );
|
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
import { Icon, server } from '@woocommerce/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import edit from './edit.js';
|
||||
|
||||
registerBlockType( 'woocommerce/attribute-filter', {
|
||||
title: __( 'Filter Products by Attribute', 'woocommerce' ),
|
||||
icon: {
|
||||
src: <Icon srcElement={ server } />,
|
||||
foreground: '#96588a',
|
||||
},
|
||||
category: 'woocommerce',
|
||||
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
|
||||
description: __(
|
||||
'Allow customers to filter the grid by product attribute, such as color. Works in combination with the All Products block.',
|
||||
'woocommerce'
|
||||
),
|
||||
supports: {
|
||||
html: false,
|
||||
},
|
||||
example: {
|
||||
attributes: {
|
||||
isPreview: true,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
attributeId: {
|
||||
type: 'number',
|
||||
default: 0,
|
||||
},
|
||||
showCounts: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
queryType: {
|
||||
type: 'string',
|
||||
default: 'or',
|
||||
},
|
||||
heading: {
|
||||
type: 'string',
|
||||
default: __(
|
||||
'Filter by attribute',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
headingLevel: {
|
||||
type: 'number',
|
||||
default: 3,
|
||||
},
|
||||
displayStyle: {
|
||||
type: 'string',
|
||||
default: 'list',
|
||||
},
|
||||
showFilterButton: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Are we previewing?
|
||||
*/
|
||||
isPreview: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
edit,
|
||||
// Save the props to post content.
|
||||
save( { attributes } ) {
|
||||
const {
|
||||
className,
|
||||
showCounts,
|
||||
queryType,
|
||||
attributeId,
|
||||
heading,
|
||||
headingLevel,
|
||||
displayStyle,
|
||||
showFilterButton,
|
||||
} = attributes;
|
||||
const data = {
|
||||
'data-attribute-id': attributeId,
|
||||
'data-show-counts': showCounts,
|
||||
'data-query-type': queryType,
|
||||
'data-heading': heading,
|
||||
'data-heading-level': headingLevel,
|
||||
};
|
||||
if ( displayStyle !== 'list' ) {
|
||||
data[ 'data-display-style' ] = displayStyle;
|
||||
}
|
||||
if ( showFilterButton ) {
|
||||
data[ 'data-show-filter-button' ] = showFilterButton;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={ classNames( 'is-loading', className ) }
|
||||
{ ...data }
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="wc-block-product-attribute-filter__placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} );
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import Label from '@woocommerce/base-components/filter-element-label';
|
||||
|
||||
export const previewOptions = [
|
||||
{
|
||||
value: 'preview-1',
|
||||
name: 'Blue',
|
||||
label: <Label name="Blue" count={ 3 } />,
|
||||
},
|
||||
{
|
||||
value: 'preview-2',
|
||||
name: 'Green',
|
||||
label: <Label name="Green" count={ 3 } />,
|
||||
},
|
||||
{
|
||||
value: 'preview-3',
|
||||
name: 'Red',
|
||||
label: <Label name="Red" count={ 2 } />,
|
||||
},
|
||||
];
|
||||
|
||||
export const previewAttributeObject = {
|
||||
id: 0,
|
||||
name: 'preview',
|
||||
taxonomy: 'preview',
|
||||
label: 'Preview',
|
||||
};
|
@ -0,0 +1,41 @@
|
||||
.wc-block-attribute-filter {
|
||||
margin-bottom: $gap-large;
|
||||
|
||||
&.style-dropdown {
|
||||
display: flex;
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter-list {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
li {
|
||||
text-decoration: underline;
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter-dropdown {
|
||||
flex-grow: 1;
|
||||
max-width: unset;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.is-single .wc-block-attribute-filter-list-count,
|
||||
.wc-block-dropdown-selector .wc-block-dropdown-selector__list .wc-block-attribute-filter-list-count {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wc-block-components-dropdown-selector__input-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
@ -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,
|
||||
},
|
||||
};
|
@ -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 );
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './columns-block';
|
@ -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 );
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
];
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
export default 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzgiIGhlaWdodD0iMzgiIHZpZXdCb3g9IjAgMCAzOCAzOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE5IDBDOC41MDQwMyAwIDAgOC41MDQwMyAwIDE5QzAgMjkuNDk2IDguNTA0MDMgMzggMTkgMzhDMjkuNDk2IDM4IDM4IDI5LjQ5NiAzOCAxOUMzOCA4LjUwNDAzIDI5LjQ5NiAwIDE5IDBaTTI1LjEyOSAxMi44NzFDMjYuNDg1MSAxMi44NzEgMjcuNTgwNiAxMy45NjY1IDI3LjU4MDYgMTUuMzIyNkMyNy41ODA2IDE2LjY3ODYgMjYuNDg1MSAxNy43NzQyIDI1LjEyOSAxNy43NzQyQzIzLjc3MyAxNy43NzQyIDIyLjY3NzQgMTYuNjc4NiAyMi42Nzc0IDE1LjMyMjZDMjIuNjc3NCAxMy45NjY1IDIzLjc3MyAxMi44NzEgMjUuMTI5IDEyLjg3MVpNMTEuNjQ1MiAzMS4yNTgxQzkuNjE0OTIgMzEuMjU4MSA3Ljk2Nzc0IDI5LjY0OTIgNy45Njc3NCAyNy42NTczQzcuOTY3NzQgMjYuMTI1IDEwLjE1MTIgMjMuMDI5OCAxMS4xNTQ4IDIxLjY5NjhDMTEuNCAyMS4zNjczIDExLjg5MDMgMjEuMzY3MyAxMi4xMzU1IDIxLjY5NjhDMTMuMTM5MSAyMy4wMjk4IDE1LjMyMjYgMjYuMTI1IDE1LjMyMjYgMjcuNjU3M0MxNS4zMjI2IDI5LjY0OTIgMTMuNjc1NCAzMS4yNTgxIDExLjY0NTIgMzEuMjU4MVpNMTIuODcxIDE3Ljc3NDJDMTEuNTE0OSAxNy43NzQyIDEwLjQxOTQgMTYuNjc4NiAxMC40MTk0IDE1LjMyMjZDMTAuNDE5NCAxMy45NjY1IDExLjUxNDkgMTIuODcxIDEyLjg3MSAxMi44NzFDMTQuMjI3IDEyLjg3MSAxNS4zMjI2IDEzLjk2NjUgMTUuMzIyNiAxNS4zMjI2QzE1LjMyMjYgMTYuNjc4NiAxNC4yMjcgMTcuNzc0MiAxMi44NzEgMTcuNzc0MlpNMjUuOTEwNSAyOS41ODc5QzI0LjE5NDQgMjcuNTM0NyAyMS42NzM4IDI2LjM1NDggMTkgMjYuMzU0OEMxNy4zNzU4IDI2LjM1NDggMTcuMzc1OCAyMy45MDMyIDE5IDIzLjkwMzJDMjIuNDAxNiAyMy45MDMyIDI1LjYxMTcgMjUuNDA0OCAyNy43ODc1IDI4LjAyNUMyOC44NDQ4IDI5LjI4MTUgMjYuOTI5NCAzMC44MjE0IDI1LjkxMDUgMjkuNTg3OVoiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo=';
|
@ -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;
|
@ -0,0 +1,4 @@
|
||||
.wc-block-cart__empty-cart__title,
|
||||
.editor-styles-wrapper .wc-block-cart__empty-cart__title {
|
||||
font-size: inherit;
|
||||
}
|
@ -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,
|
||||
} );
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
@ -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 );
|
@ -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
|
||||
}
|
@ -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;
|
@ -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() } />;
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
} );
|
@ -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
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
} );
|
@ -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
|
||||
}
|
@ -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;
|
@ -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() } />;
|
||||
};
|
@ -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,
|
||||
} );
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
@ -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
|
||||
}
|
@ -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;
|
@ -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() } />;
|
||||
};
|
@ -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,
|
||||
} );
|
@ -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
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
} );
|
@ -0,0 +1,8 @@
|
||||
.is-mobile,
|
||||
.is-small,
|
||||
.is-medium {
|
||||
.wc-block-cart__sidebar {
|
||||
margin-bottom: $gap-large;
|
||||
order: 0;
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
} );
|
@ -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
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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,
|
||||
} );
|
@ -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';
|
@ -0,0 +1,13 @@
|
||||
export default {
|
||||
checkoutPageId: {
|
||||
type: 'number',
|
||||
default: 0,
|
||||
},
|
||||
lock: {
|
||||
type: 'object',
|
||||
default: {
|
||||
move: true,
|
||||
remove: true,
|
||||
},
|
||||
},
|
||||
};
|
@ -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
|
||||
}
|
@ -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;
|
@ -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() } />;
|
||||
};
|
@ -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 );
|
@ -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,
|
||||
} );
|
@ -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'
|
||||
)
|
||||
),
|
||||
} );
|
@ -0,0 +1,7 @@
|
||||
.wp-block-woocommerce-cart.is-loading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart {
|
||||
margin-bottom: 3em;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 ] );
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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 );
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
@ -0,0 +1,8 @@
|
||||
.wc-block-cart__page-notice {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart.is-editor-preview {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export default 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzgiIGhlaWdodD0iMzgiIHZpZXdCb3g9IjAgMCAzOCAzOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE5IDBDOC41MDQwMyAwIDAgOC41MDQwMyAwIDE5QzAgMjkuNDk2IDguNTA0MDMgMzggMTkgMzhDMjkuNDk2IDM4IDM4IDI5LjQ5NiAzOCAxOUMzOCA4LjUwNDAzIDI5LjQ5NiAwIDE5IDBaTTI1LjEyOSAxMi44NzFDMjYuNDg1MSAxMi44NzEgMjcuNTgwNiAxMy45NjY1IDI3LjU4MDYgMTUuMzIyNkMyNy41ODA2IDE2LjY3ODYgMjYuNDg1MSAxNy43NzQyIDI1LjEyOSAxNy43NzQyQzIzLjc3MyAxNy43NzQyIDIyLjY3NzQgMTYuNjc4NiAyMi42Nzc0IDE1LjMyMjZDMjIuNjc3NCAxMy45NjY1IDIzLjc3MyAxMi44NzEgMjUuMTI5IDEyLjg3MVpNMTEuNjQ1MiAzMS4yNTgxQzkuNjE0OTIgMzEuMjU4MSA3Ljk2Nzc0IDI5LjY0OTIgNy45Njc3NCAyNy42NTczQzcuOTY3NzQgMjYuMTI1IDEwLjE1MTIgMjMuMDI5OCAxMS4xNTQ4IDIxLjY5NjhDMTEuNCAyMS4zNjczIDExLjg5MDMgMjEuMzY3MyAxMi4xMzU1IDIxLjY5NjhDMTMuMTM5MSAyMy4wMjk4IDE1LjMyMjYgMjYuMTI1IDE1LjMyMjYgMjcuNjU3M0MxNS4zMjI2IDI5LjY0OTIgMTMuNjc1NCAzMS4yNTgxIDExLjY0NTIgMzEuMjU4MVpNMTIuODcxIDE3Ljc3NDJDMTEuNTE0OSAxNy43NzQyIDEwLjQxOTQgMTYuNjc4NiAxMC40MTk0IDE1LjMyMjZDMTAuNDE5NCAxMy45NjY1IDExLjUxNDkgMTIuODcxIDEyLjg3MSAxMi44NzFDMTQuMjI3IDEyLjg3MSAxNS4zMjI2IDEzLjk2NjUgMTUuMzIyNiAxNS4zMjI2QzE1LjMyMjYgMTYuNjc4NiAxNC4yMjcgMTcuNzc0MiAxMi44NzEgMTcuNzc0MlpNMjUuOTEwNSAyOS41ODc5QzI0LjE5NDQgMjcuNTM0NyAyMS42NzM4IDI2LjM1NDggMTkgMjYuMzU0OEMxNy4zNzU4IDI2LjM1NDggMTcuMzc1OCAyMy45MDMyIDE5IDIzLjkwMzJDMjIuNDAxNiAyMy45MDMyIDI1LjYxMTcgMjUuNDA0OCAyNy43ODc1IDI4LjAyNUMyOC44NDQ4IDI5LjI4MTUgMjYuOTI5NCAzMC44MjE0IDI1LjkxMDUgMjkuNTg3OVoiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo=';
|
@ -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;
|
@ -0,0 +1,4 @@
|
||||
.wc-block-cart__empty-cart__title,
|
||||
.editor-styles-wrapper .wc-block-cart__empty-cart__title {
|
||||
font-size: inherit;
|
||||
}
|
@ -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,
|
||||
} );
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 );
|
@ -0,0 +1,7 @@
|
||||
.wp-block-woocommerce-cart.is-loading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart {
|
||||
margin-bottom: 3em;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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€' );
|
||||
} );
|
||||
} );
|
@ -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 ),
|
||||
},
|
||||
};
|
@ -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 );
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user