This commit is contained in:
KhaiNguyen
2020-02-13 10:39:37 +07:00
commit 59401cb805
12867 changed files with 4646216 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { Disabled } from '@wordpress/components';
import Gridicon from 'gridicons';
import { ProductButton } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Button', 'woocommerce' ),
description: __(
'Display a call to action button which either adds the product to the cart, or links to the product page.',
'woocommerce'
),
icon: {
src: <Gridicon icon="cart" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return (
<Disabled>
<ProductButton product={ attributes.product } />
</Disabled>
);
},
};
registerBlockType( 'woocommerce/product-button', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,145 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { Fragment } from '@wordpress/element';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { InspectorControls } from '@wordpress/block-editor';
import ToggleButtonControl from '@woocommerce/block-components/toggle-button-control';
import { ProductImage } from '@woocommerce/atomic-components/product';
import { previewProducts } from '@woocommerce/resource-previews';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Image', 'woocommerce' ),
description: __(
'Display the main product image',
'woocommerce'
),
icon: {
src: <Gridicon icon="image" />,
foreground: '#96588a',
},
attributes: {
product: {
type: 'object',
default: previewProducts[ 0 ],
},
productLink: {
type: 'boolean',
default: true,
},
showSaleBadge: {
type: 'boolean',
default: true,
},
saleBadgeAlign: {
type: 'string',
default: 'right',
},
},
edit( props ) {
const { attributes, setAttributes } = props;
const { productLink, showSaleBadge, saleBadgeAlign } = attributes;
return (
<Fragment>
<InspectorControls>
<PanelBody
title={ __(
'Content',
'woocommerce'
) }
>
<ToggleControl
label={ __(
'Link to Product Page',
'woocommerce'
) }
help={ __(
'Links the image to the single product listing.',
'woocommerce'
) }
checked={ productLink }
onChange={ () =>
setAttributes( {
productLink: ! productLink,
} )
}
/>
<ToggleControl
label={ __(
'Show On-Sale Badge',
'woocommerce'
) }
help={ __(
'Overlay a "sale" badge if the product is on-sale.',
'woocommerce'
) }
checked={ showSaleBadge }
onChange={ () =>
setAttributes( {
showSaleBadge: ! showSaleBadge,
} )
}
/>
{ showSaleBadge && (
<ToggleButtonControl
label={ __(
'Sale Badge Alignment',
'woocommerce'
) }
value={ saleBadgeAlign }
options={ [
{
label: __(
'Left',
'woocommerce'
),
value: 'left',
},
{
label: __(
'Center',
'woocommerce'
),
value: 'center',
},
{
label: __(
'Right',
'woocommerce'
),
value: 'right',
},
] }
onChange={ ( value ) =>
setAttributes( { saleBadgeAlign: value } )
}
/>
) }
</PanelBody>
</InspectorControls>
<Disabled>
<ProductImage
product={ attributes.product }
productLink={ productLink }
showSaleBadge={ showSaleBadge }
saleBadgeAlign={ saleBadgeAlign }
/>
</Disabled>
</Fragment>
);
},
};
registerBlockType( 'woocommerce/product-image', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,7 @@
export { default as ProductTitle } from './title';
export { default as ProductPrice } from './price';
export { default as ProductImage } from './image';
export { default as ProductRating } from './rating';
export { default as ProductButton } from './button';
export { default as ProductSummary } from './summary';
export { default as ProductSaleBadge } from './sale-badge';

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { ProductPrice } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Price', 'woocommerce' ),
description: __(
'Display the price of a product.',
'woocommerce'
),
icon: {
src: <Gridicon icon="money" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return <ProductPrice product={ attributes.product } />;
},
};
registerBlockType( 'woocommerce/product-price', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { ProductRating } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Rating', 'woocommerce' ),
description: __(
'Display the average rating of a product.',
'woocommerce'
),
icon: {
src: <Gridicon icon="star-outline" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return <ProductRating product={ attributes.product } />;
},
};
registerBlockType( 'woocommerce/product-rating', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { ProductSaleBadge } from '@woocommerce/atomic-components/product';
import { IconProductOnSale } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'On-Sale Badge', 'woocommerce' ),
description: __(
'Displays an on-sale badge if the product is on-sale.',
'woocommerce'
),
icon: {
src: <IconProductOnSale />,
foreground: '#96588a',
},
supports: {
html: false,
},
edit( props ) {
const { align, product } = props.attributes;
return <ProductSaleBadge product={ product } align={ align } />;
},
};
registerBlockType( 'woocommerce/product-sale-badge', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import Gridicon from 'gridicons';
import { previewProducts } from '@woocommerce/resource-previews';
/**
* Holds default config for this collection of blocks.
*/
export default {
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
icon: {
src: <Gridicon icon="grid" />,
foreground: '#96588a',
},
supports: {
html: false,
},
parent: [ 'woocommerce/all-products' ],
attributes: {
product: {
type: 'object',
default: previewProducts[ 0 ],
},
},
save() {},
};

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { ProductSummary } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Summary', 'woocommerce' ),
description: __(
'Display the short description of a product.',
'woocommerce'
),
icon: {
src: <Gridicon icon="aside" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return <ProductSummary product={ attributes.product } />;
},
};
registerBlockType( 'woocommerce/product-summary', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { Fragment } from 'react';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { InspectorControls } from '@wordpress/block-editor';
import { ProductTitle } from '@woocommerce/atomic-components/product';
import { previewProducts } from '@woocommerce/resource-previews';
import HeadingToolbar from '@woocommerce/block-components/heading-toolbar';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Title', 'woocommerce' ),
description: __(
'Display the name of a product.',
'woocommerce'
),
icon: {
src: 'heading',
foreground: '#96588a',
},
attributes: {
product: {
type: 'object',
default: previewProducts[ 0 ],
},
headingLevel: {
type: 'number',
default: 2,
},
productLink: {
type: 'boolean',
default: true,
},
},
edit( props ) {
const { attributes, setAttributes } = props;
const { headingLevel, productLink } = attributes;
return (
<Fragment>
<InspectorControls>
<PanelBody
title={ __(
'Content',
'woocommerce'
) }
>
<p>{ __( 'Level', 'woocommerce' ) }</p>
<HeadingToolbar
isCollapsed={ false }
minLevel={ 2 }
maxLevel={ 7 }
selectedLevel={ headingLevel }
onChange={ ( newLevel ) =>
setAttributes( { headingLevel: newLevel } )
}
/>
<ToggleControl
label={ __(
'Link to Product Page',
'woocommerce'
) }
help={ __(
'Links the image to the single product listing.',
'woocommerce'
) }
checked={ productLink }
onChange={ () =>
setAttributes( {
productLink: ! productLink,
} )
}
/>
</PanelBody>
</InspectorControls>
<Disabled>
<ProductTitle
headingLevel={ headingLevel }
productLink={ productLink }
product={ attributes.product }
/>
</Disabled>
</Fragment>
);
},
};
registerBlockType( 'woocommerce/product-title', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,199 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { _n, sprintf } from '@wordpress/i18n';
import {
useMemo,
useCallback,
useState,
useEffect,
useRef,
} from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { find } from 'lodash';
import { useCollection } from '@woocommerce/base-hooks';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
import { decodeEntities } from '@wordpress/html-entities';
/**
* A custom hook for exposing cart related data for a given product id and an
* action for adding a single quantity of the product _to_ the cart.
*
* Currently this is internal only to the ProductButton component until we have
* a clearer idea of the pattern that should emerge for a cart hook.
*
* @param {number} productId The product id for the product connection to the
* cart.
*
* @return {Object} Returns an object with the following properties:
* @type {number} cartQuantity The quantity of the product currently in
* the cart.
* @type {bool} addingToCart Whether the product is currently being
* added to the cart (true).
* @type {bool} cartIsLoading Whether the cart is being loaded.
* @type {Function} addToCart An action dispatcher for adding a single
* quantity of the product to the cart.
* Receives no arguments, it operates on the
* current product.
*/
const useAddToCart = ( productId ) => {
const { results: cartResults, isLoading: cartIsLoading } = useCollection( {
namespace: '/wc/store',
resourceName: 'cart/items',
} );
const currentCartResults = useRef( null );
const { __experimentalPersistItemToCollection } = useDispatch( storeKey );
const cartQuantity = useMemo( () => {
const productItem = find( cartResults, { id: productId } );
return productItem ? productItem.quantity : 0;
}, [ cartResults, productId ] );
const [ addingToCart, setAddingToCart ] = useState( false );
const addToCart = useCallback( () => {
setAddingToCart( true );
// exclude this item from the cartResults for adding to the new
// collection (so it's updated correctly!)
const collection = cartResults.filter( ( cartItem ) => {
return cartItem.id !== productId;
} );
__experimentalPersistItemToCollection(
'/wc/store',
'cart/items',
collection,
{ id: productId, quantity: 1 }
);
}, [ productId, cartResults ] );
useEffect( () => {
if ( currentCartResults.current !== cartResults ) {
if ( addingToCart ) {
setAddingToCart( false );
}
currentCartResults.current = cartResults;
}
}, [ cartResults, addingToCart ] );
return {
cartQuantity,
addingToCart,
cartIsLoading,
addToCart,
};
};
const Event = window.Event || {};
const ProductButton = ( { product, className } ) => {
const {
id,
permalink,
add_to_cart: productCartDetails,
has_options: hasOptions,
is_purchasable: isPurchasable,
is_in_stock: isInStock,
} = product;
const {
cartQuantity,
addingToCart,
cartIsLoading,
addToCart,
} = useAddToCart( id );
const { layoutStyleClassPrefix } = useProductLayoutContext();
const addedToCart = cartQuantity > 0;
const firstMount = useRef( true );
const getButtonText = () => {
if ( Number.isFinite( cartQuantity ) && addedToCart ) {
return sprintf(
// translators: %s number of products in cart.
_n(
'%d in cart',
'%d in cart',
cartQuantity,
'woocommerce'
),
cartQuantity
);
}
return decodeEntities( productCartDetails.text );
};
// This is a hack to trigger cart updates till we migrate to block based card
// that relies on the store, see
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1247
useEffect( () => {
if ( firstMount.current ) {
firstMount.current = false;
return;
}
// Test if we have our Event defined
if ( Object.entries( Event ).length !== 0 ) {
const event = new Event( 'wc_fragment_refresh', {
bubbles: true,
cancelable: true,
} );
document.body.dispatchEvent( event );
} else {
const event = document.createEvent( 'Event' );
event.initEvent( 'wc_fragment_refresh', true, true );
document.body.dispatchEvent( event );
}
}, [ cartQuantity ] );
const wrapperClasses = classnames(
className,
`${ layoutStyleClassPrefix }__product-add-to-cart`,
'wp-block-button'
);
const buttonClasses = classnames(
'wp-block-button__link',
'add_to_cart_button',
{
loading: addingToCart,
added: addedToCart,
}
);
if ( Object.keys( product ).length === 0 || cartIsLoading ) {
return (
<div className={ wrapperClasses }>
<button className={ buttonClasses } disabled={ true } />
</div>
);
}
const allowAddToCart = ! hasOptions && isPurchasable && isInStock;
return (
<div className={ wrapperClasses }>
{ allowAddToCart ? (
<button
onClick={ addToCart }
aria-label={ decodeEntities(
productCartDetails.description
) }
className={ buttonClasses }
disabled={ addingToCart }
>
{ getButtonText() }
</button>
) : (
<a
href={ permalink }
aria-label={ decodeEntities(
productCartDetails.description
) }
className={ buttonClasses }
rel="nofollow"
>
{ getButtonText() }
</a>
) }
</div>
);
};
ProductButton.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductButton;

View File

@@ -0,0 +1,105 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { Fragment, useState } from '@wordpress/element';
import classnames from 'classnames';
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/block-settings';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
/**
* Internal dependencies
*/
import { ProductSaleBadge } from '../../../components/product';
const SaleBadge = ( { product, saleBadgeAlign, shouldRender } ) => {
return shouldRender ? (
<ProductSaleBadge product={ product } align={ saleBadgeAlign } />
) : null;
};
const Image = ( { layoutPrefix, loaded, image, onLoad } ) => {
const cssClass = classnames( `${ layoutPrefix }__product-image__image`, {
[ `${ layoutPrefix }__product-image__image_placeholder` ]:
! loaded && ! image,
} );
const { thumbnail, srcset, sizes, alt } = image || {};
return (
<Fragment>
{ image && (
<img
className={ cssClass }
src={ thumbnail }
srcSet={ srcset }
sizes={ sizes }
alt={ alt }
onLoad={ onLoad }
hidden={ ! loaded }
/>
) }
{ ! loaded && (
<img
className={ cssClass }
src={ PLACEHOLDER_IMG_SRC }
alt=""
/>
) }
</Fragment>
);
};
const ProductImage = ( {
className,
product,
productLink = true,
showSaleBadge = true,
saleBadgeAlign = 'right',
} ) => {
const [ imageLoaded, setImageLoaded ] = useState( false );
const { layoutStyleClassPrefix } = useProductLayoutContext();
const image =
product.images && product.images.length ? product.images[ 0 ] : null;
const renderedSalesAndImage = (
<Fragment>
<SaleBadge
product={ product }
saleBadgeAlign={ saleBadgeAlign }
shouldRender={ showSaleBadge }
/>
<Image
layoutPrefix={ layoutStyleClassPrefix }
loaded={ imageLoaded }
image={ image }
onLoad={ () => setImageLoaded( true ) }
/>
</Fragment>
);
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-image`
) }
>
{ productLink ? (
<a href={ product.permalink } rel="nofollow">
{ renderedSalesAndImage }
</a>
) : (
renderedSalesAndImage
) }
</div>
);
};
ProductImage.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
productLink: PropTypes.bool,
showSaleBadge: PropTypes.bool,
saleBadgeAlign: PropTypes.string,
};
export default ProductImage;

View File

@@ -0,0 +1,7 @@
export { default as ProductButton } from './button';
export { default as ProductImage } from './image';
export { default as ProductRating } from './rating';
export { default as ProductTitle } from './title';
export { default as ProductPrice } from './price';
export { default as ProductSummary } from './summary';
export { default as ProductSaleBadge } from './sale-badge';

View File

@@ -0,0 +1,72 @@
/**
* External dependencies
*/
import NumberFormat from 'react-number-format';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductPrice = ( { className, product } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
const prices = product.prices || {};
const numberFormatArgs = {
displayType: 'text',
thousandSeparator: prices.thousand_separator,
decimalSeparator: prices.decimal_separator,
decimalScale: prices.decimals,
fixedDecimalScale: true,
prefix: prices.price_prefix,
suffix: prices.price_suffix,
};
if (
prices.price_range &&
prices.price_range.min_amount &&
prices.price_range.max_amount
) {
const minAmount = parseFloat( prices.price_range.min_amount );
const maxAmount = parseFloat( prices.price_range.max_amount );
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-price`
) }
>
<span
className={ `${ layoutStyleClassPrefix }__product-price__value` }
>
<NumberFormat value={ minAmount } { ...numberFormatArgs } />
&nbsp;&mdash;&nbsp;
<NumberFormat value={ maxAmount } { ...numberFormatArgs } />
</span>
</div>
);
}
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-price`
) }
>
{ prices.regular_price !== prices.price && (
<del
className={ `${ layoutStyleClassPrefix }__product-price__regular` }
>
<NumberFormat
value={ prices.regular_price }
{ ...numberFormatArgs }
/>
</del>
) }
<span
className={ `${ layoutStyleClassPrefix }__product-price__value` }
>
<NumberFormat value={ prices.price } { ...numberFormatArgs } />
</span>
</div>
);
};
export default ProductPrice;

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductRating = ( { className, product } ) => {
const rating = parseFloat( product.average_rating );
const { layoutStyleClassPrefix } = useProductLayoutContext();
if ( ! Number.isFinite( rating ) || rating === 0 ) {
return null;
}
const starStyle = {
width: ( rating / 5 ) * 100 + '%',
};
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-rating`
) }
>
<div
className={ `${ layoutStyleClassPrefix }__product-rating__stars` }
role="img"
>
<span style={ starStyle }>
{ sprintf(
__(
'Rated %d out of 5',
'woocommerce'
),
rating
) }
</span>
</div>
</div>
);
};
ProductRating.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductRating;

View File

@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductSaleBadge = ( { className, product, align } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
const alignClass =
typeof align === 'string'
? `${ layoutStyleClassPrefix }__product-onsale--align${ align }`
: '';
if ( product && product.on_sale ) {
return (
<div
className={ classnames(
className,
alignClass,
`${ layoutStyleClassPrefix }__product-onsale`
) }
>
{ __( 'Sale', 'woocommerce' ) }
</div>
);
}
return null;
};
export default ProductSaleBadge;

View File

@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductSummary = ( { className, product } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
if ( ! product.description ) {
return null;
}
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-summary`
) }
dangerouslySetInnerHTML={ {
__html: product.description,
} }
/>
);
};
ProductSummary.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductSummary;

View File

@@ -0,0 +1,48 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
import { decodeEntities } from '@wordpress/html-entities';
const ProductTitle = ( {
className,
product,
headingLevel = 2,
productLink = true,
} ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
if ( ! product.name ) {
return null;
}
const productName = decodeEntities( product.name );
const TagName = `h${ headingLevel }`;
return (
<TagName
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-title`
) }
>
{ productLink ? (
<a href={ product.permalink } rel="nofollow">
{ productName }
</a>
) : (
productName
) }
</TagName>
);
};
ProductTitle.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
headingLevel: PropTypes.number,
productLink: PropTypes.bool,
};
export default ProductTitle;