Refactor Featured Category and Featured Product blocks (https://github.com/woocommerce/woocommerce-blocks/pull/6406)

This PR creates a new directory called `featured-items` which includes both blocks.
All the shared code lives at the top level of that directory.

Individual blocks still have their own directories, with their `block.json` and all other relevant configuration.

All the functionalities have been refactored out into their own files, accepting configuration when relevant, but mostly de-duplicating all the code.

Styles have also been refactored using mixins and extends and they mostly live in one place.
This commit is contained in:
Lucio Giannotta 2022-05-19 18:16:46 +02:00 committed by GitHub
parent b1f4e35c00
commit 7fcc561db1
45 changed files with 1780 additions and 2248 deletions

View File

@ -176,6 +176,61 @@ $fontSizes: (
-ms-word-break: break-all;
}
// Add support for content alignment classes
@mixin with-alignment {
// Apply max-width to floated items that have no intrinsic width
&.alignleft,
&.alignright {
max-width: $content-width * 0.5;
width: 100%;
}
// Using flexbox without an assigned height property breaks vertical center alignment in IE11.
// Appending an empty ::after element tricks IE11 into giving the cover image an implicit height, which sidesteps this issue.
&::after {
display: block;
content: "";
font-size: 0;
min-height: inherit;
// IE doesn't support flex so omit that.
@supports (position: sticky) {
content: none;
}
}
// Aligned cover blocks should not use our global alignment rules
&.aligncenter,
&.alignleft,
&.alignright {
display: flex;
}
}
// Shows an semi-transparent overlay
@mixin with-background-dim($opacity: 0.5) {
&.has-background-dim {
.background-dim__overlay::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: inherit;
border-radius: inherit;
opacity: $opacity;
z-index: 1;
}
}
@for $i from 1 through 10 {
&.has-background-dim-#{ $i * 10 } .background-dim__overlay::before {
opacity: $i * 0.1;
}
}
}
// Shows a border with the current color and a custom opacity. That can't be achieved
// with normal border because `currentColor` doesn't allow tweaking the opacity, and
// setting the opacity of the entire element would change the children's opacity too.

View File

@ -1,700 +0,0 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { useCallback, useEffect, useState } from 'react';
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
InnerBlocks,
InspectorControls,
MediaReplaceFlow,
RichText,
__experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles,
__experimentalImageEditingProvider as ImageEditingProvider,
__experimentalImageEditor as ImageEditor,
__experimentalPanelColorGradientSettings as PanelColorGradientSettings,
__experimentalUseGradient as useGradient,
} from '@wordpress/block-editor';
import {
Button,
FocalPointPicker,
PanelBody,
Placeholder,
RangeControl,
Spinner,
ToggleControl,
ToolbarButton,
ToolbarGroup,
withSpokenMessages,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
TextareaControl,
ExternalLink,
} from '@wordpress/components';
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import { withSelect } from '@wordpress/data';
import { compose, createHigherOrderComponent } from '@wordpress/compose';
import PropTypes from 'prop-types';
import { folderStarred } from '@woocommerce/icons';
import { crop, Icon } from '@wordpress/icons';
import ProductCategoryControl from '@woocommerce/editor-components/product-category-control';
import ErrorPlaceholder from '@woocommerce/editor-components/error-placeholder';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
/**
* Internal dependencies
*/
import {
dimRatioToClass,
getCategoryImageId,
getCategoryImageSrc,
calculateBackgroundImagePosition,
} from './utils';
import { withCategory } from '../../hocs';
import { ConstrainedResizable } from '../featured-product/block';
const DEFAULT_EDITOR_SIZE = {
height: 500,
width: 500,
};
/**
* Component to handle edit mode of "Featured Category".
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.attributes Incoming block attributes.
* @param {boolean} props.isSelected Whether block is selected or not.
* @param {function(any):any} props.setAttributes Function for setting new attributes.
* @param {string} props.error Error message
* @param {function(any):any} props.getCategory Function for getting category details.
* @param {boolean} props.isLoading Whether loading or not.
* @param {Object} props.category The product category object.
* @param {function(any):any} props.debouncedSpeak Function for delayed speak.
* @param {function():void} props.triggerUrlUpdate Function to update Shop now button Url.
*/
const FeaturedCategory = ( {
attributes,
isSelected,
setAttributes,
error,
getCategory,
isLoading,
category,
debouncedSpeak,
triggerUrlUpdate = () => void null,
} ) => {
const { mediaId, mediaSrc } = attributes;
const [ isEditingImage, setIsEditingImage ] = useState( false );
const [ backgroundImageSize, setBackgroundImageSize ] = useState( {} );
const { setGradient } = useGradient( {
gradientAttribute: 'overlayGradient',
customGradientAttribute: 'overlayGradient',
} );
const backgroundImageSrc = mediaSrc || getCategoryImageSrc( category );
const backgroundImageId = mediaId || getCategoryImageId( category );
const onResize = useCallback(
( _event, _direction, elt ) => {
setAttributes( { minHeight: parseInt( elt.style.height, 10 ) } );
},
[ setAttributes ]
);
useEffect( () => {
setIsEditingImage( false );
}, [ isSelected ] );
const renderApiError = () => (
<ErrorPlaceholder
className="wc-block-featured-category-error"
error={ error }
isLoading={ isLoading }
onRetry={ getCategory }
/>
);
const getBlockControls = () => {
const { contentAlign, editMode } = attributes;
return (
<BlockControls>
<AlignmentToolbar
value={ contentAlign }
onChange={ ( nextAlign ) => {
setAttributes( { contentAlign: nextAlign } );
} }
/>
<ToolbarGroup>
{ backgroundImageSrc && ! isEditingImage && (
<ToolbarButton
onClick={ () => setIsEditingImage( true ) }
icon={ crop }
label={ __(
'Edit category image',
'woo-gutenberg-products-block'
) }
/>
) }
<MediaReplaceFlow
mediaId={ backgroundImageId }
mediaURL={ mediaSrc }
accept="image/*"
onSelect={ ( media ) => {
setAttributes( {
mediaId: media.id,
mediaSrc: media.url,
} );
} }
allowedTypes={ [ 'image' ] }
/>
{ backgroundImageId && mediaSrc ? (
<TextToolbarButton
onClick={ () =>
setAttributes( { mediaId: 0, mediaSrc: '' } )
}
>
{ __( 'Reset', 'woo-gutenberg-products-block' ) }
</TextToolbarButton>
) : null }
</ToolbarGroup>
<ToolbarGroup
controls={ [
{
icon: 'edit',
title: __(
'Edit selected category',
'woo-gutenberg-products-block'
),
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
);
};
const getInspectorControls = () => {
const { focalPoint = { x: 0.5, y: 0.5 } } = attributes;
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
// so we need to check if it exists before using it.
const focalPointPickerExists = typeof FocalPointPicker === 'function';
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woo-gutenberg-products-block' ) }
>
<ToggleControl
label={ __(
'Show description',
'woo-gutenberg-products-block'
) }
checked={ attributes.showDesc }
onChange={ () =>
setAttributes( { showDesc: ! attributes.showDesc } )
}
/>
</PanelBody>
{ !! backgroundImageSrc && (
<>
{ focalPointPickerExists && (
<PanelBody
title={ __(
'Media settings',
'woo-gutenberg-products-block'
) }
>
<ToggleGroupControl
help={
<>
<p>
{ __(
'Choose “Cover” if you want the image to scale automatically to always fit its container.',
'woo-gutenberg-products-block'
) }
</p>
<p>
{ __(
'Note: by choosing “Cover” you will lose the ability to freely move the focal point precisely.',
'woo-gutenberg-products-block'
) }
</p>
</>
}
label={ __(
'Image fit',
'woo-gutenberg-products-block'
) }
value={ attributes.imageFit }
onChange={ ( value ) =>
setAttributes( {
imageFit: value,
} )
}
>
<ToggleGroupControlOption
label={ __(
'None',
'woo-gutenberg-products-block'
) }
value="none"
/>
<ToggleGroupControlOption
/* translators: "Cover" is a verb that indicates an image covering the entire container. */
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
value="cover"
/>
</ToggleGroupControl>
<FocalPointPicker
label={ __(
'Focal Point Picker',
'woo-gutenberg-products-block'
) }
url={ backgroundImageSrc }
value={ focalPoint }
onChange={ ( value ) =>
setAttributes( {
focalPoint: value,
} )
}
/>
<TextareaControl
label={ __(
'Alt text (alternative text)',
'woo-gutenberg-products-block'
) }
value={ attributes.alt }
onChange={ ( alt ) => {
setAttributes( { alt } );
} }
help={
<>
<ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree">
{ __(
'Describe the purpose of the image',
'woo-gutenberg-products-block'
) }
</ExternalLink>
{ __(
'Leaving it empty will use the category name.',
'woo-gutenberg-products-block'
) }
</>
}
/>
</PanelBody>
) }
<PanelColorGradientSettings
__experimentalHasMultipleOrigins
__experimentalIsRenderedInSidebar
title={ __(
'Overlay',
'woo-gutenberg-products-block'
) }
initialOpen={ true }
settings={ [
{
colorValue: attributes.overlayColor,
gradientValue: attributes.overlayGradient,
onColorChange: ( overlayColor ) =>
setAttributes( { overlayColor } ),
onGradientChange: ( overlayGradient ) => {
setGradient( overlayGradient );
setAttributes( {
overlayGradient,
} );
},
label: __(
'Color',
'woo-gutenberg-products-block'
),
},
] }
>
<RangeControl
label={ __(
'Opacity',
'woo-gutenberg-products-block'
) }
value={ attributes.dimRatio }
onChange={ ( dimRatio ) =>
setAttributes( { dimRatio } )
}
min={ 0 }
max={ 100 }
step={ 10 }
required
/>
</PanelColorGradientSettings>
</>
) }
</InspectorControls>
);
};
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Featured Product block preview.',
'woo-gutenberg-products-block'
)
);
};
return (
<Placeholder
icon={ <Icon icon={ folderStarred } /> }
label={ __(
'Featured Category',
'woo-gutenberg-products-block'
) }
className="wc-block-featured-category"
>
{ __(
'Visually highlight a product category and encourage prompt action.',
'woo-gutenberg-products-block'
) }
<div className="wc-block-featured-category__selection">
<ProductCategoryControl
selected={ [ attributes.categoryId ] }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
categoryId: id,
mediaId: 0,
mediaSrc: '',
} );
triggerUrlUpdate();
} }
isSingle
/>
<Button isPrimary onClick={ onDone }>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</div>
</Placeholder>
);
};
const renderButton = () => {
const buttonClasses = classnames(
'wp-block-button__link',
'is-style-fill'
);
const buttonStyle = {
backgroundColor: 'vivid-green-cyan',
borderRadius: '5px',
};
const wrapperStyle = {
width: '100%',
};
return attributes.categoryId === 'preview' ? (
<div className="wp-block-button aligncenter" style={ wrapperStyle }>
<RichText.Content
tagName="a"
className={ buttonClasses }
href={ category.permalink }
title={ attributes.linkText }
style={ buttonStyle }
value={ attributes.linkText }
target={ category.permalink }
/>
</div>
) : (
<InnerBlocks
template={ [
[
'core/buttons',
{
layout: { type: 'flex', justifyContent: 'center' },
},
[
[
'core/button',
{
text: __(
'Shop now',
'woo-gutenberg-products-block'
),
url: category.permalink,
},
],
],
],
] }
templateLock="all"
/>
);
};
const renderCategory = () => {
const {
contentAlign,
dimRatio,
focalPoint,
imageFit,
minHeight,
overlayColor,
overlayGradient,
showDesc,
style,
} = attributes;
const classes = classnames(
'wc-block-featured-category',
{
'is-selected': isSelected && attributes.productId !== 'preview',
'is-loading': ! category && isLoading,
'is-not-found': ! category && ! isLoading,
'has-background-dim': dimRatio !== 0,
},
dimRatioToClass( dimRatio ),
contentAlign !== 'center' && `has-${ contentAlign }-content`
);
const containerStyle = {
borderRadius: style?.border?.radius,
};
const wrapperStyle = {
...getSpacingClassesAndStyles( attributes ).style,
minHeight,
};
const backgroundImageStyle = {
...calculateBackgroundImagePosition( focalPoint ),
objectFit: imageFit,
};
const overlayStyle = {
background: overlayGradient,
backgroundColor: overlayColor,
};
return (
<>
<ConstrainedResizable
enable={ { bottom: true } }
onResize={ onResize }
showHandle={ isSelected }
style={ { minHeight } }
/>
<div className={ classes } style={ containerStyle }>
<div
className="wc-block-featured-category__wrapper"
style={ wrapperStyle }
>
<div
className="wc-block-featured-category__overlay"
style={ overlayStyle }
/>
{ backgroundImageSrc && (
<img
alt={ category.description }
className="wc-block-featured-category__background-image"
src={ backgroundImageSrc }
style={ backgroundImageStyle }
onLoad={ ( e ) => {
setBackgroundImageSize( {
height: e.target?.naturalHeight,
width: e.target?.naturalWidth,
} );
} }
/>
) }
<h2
className="wc-block-featured-category__title"
dangerouslySetInnerHTML={ {
__html: category.name,
} }
/>
{ showDesc && (
<div
className="wc-block-featured-category__description"
dangerouslySetInnerHTML={ {
__html: category.description,
} }
/>
) }
<div className="wc-block-featured-category__link">
{ renderButton() }
</div>
</div>
</div>
</>
);
};
const renderNoCategory = () => (
<Placeholder
className="wc-block-featured-category"
icon={ <Icon icon={ folderStarred } /> }
label={ __( 'Featured Category', 'woo-gutenberg-products-block' ) }
>
{ isLoading ? (
<Spinner />
) : (
__(
'No product category is selected.',
'woo-gutenberg-products-block'
)
) }
</Placeholder>
);
const { editMode } = attributes;
if ( error ) {
return renderApiError();
}
if ( editMode ) {
return renderEditMode();
}
if ( isEditingImage ) {
return (
<>
<ImageEditingProvider
id={ backgroundImageId }
url={ backgroundImageSrc }
naturalHeight={
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
}
naturalWidth={
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
}
onSaveImage={ ( { id, url } ) => {
setAttributes( { mediaId: id, mediaSrc: url } );
} }
isEditing={ isEditingImage }
onFinishEditing={ () => setIsEditingImage( false ) }
>
<ImageEditor
url={ backgroundImageSrc }
height={
backgroundImageSize.height ||
DEFAULT_EDITOR_SIZE.height
}
width={
backgroundImageSize.width ||
DEFAULT_EDITOR_SIZE.width
}
/>
</ImageEditingProvider>
</>
);
}
return (
<>
{ getBlockControls() }
{ getInspectorControls() }
{ category ? renderCategory() : renderNoCategory() }
</>
);
};
FeaturedCategory.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* Whether this block is currently active.
*/
isSelected: PropTypes.bool.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withCategory
error: PropTypes.object,
getCategory: PropTypes.func,
isLoading: PropTypes.bool,
category: PropTypes.shape( {
name: PropTypes.node,
description: PropTypes.node,
permalink: PropTypes.string,
} ),
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
triggerUrlUpdate: PropTypes.func,
};
export default compose( [
withCategory,
withSpokenMessages,
withSelect( ( select, { clientId }, { dispatch } ) => {
const Block = select( 'core/block-editor' ).getBlock( clientId );
const buttonBlockId = Block?.innerBlocks[ 0 ]?.clientId || '';
const currentButtonAttributes =
Block?.innerBlocks[ 0 ]?.attributes || {};
const updateBlockAttributes = ( attributes ) => {
if ( buttonBlockId ) {
dispatch( 'core/block-editor' ).updateBlockAttributes(
buttonBlockId,
attributes
);
}
};
return { updateBlockAttributes, currentButtonAttributes };
} ),
createHigherOrderComponent( ( ProductComponent ) => {
class WrappedComponent extends Component {
state = {
doUrlUpdate: false,
};
componentDidUpdate() {
const {
attributes,
updateBlockAttributes,
currentButtonAttributes,
category,
} = this.props;
if (
this.state.doUrlUpdate &&
! attributes.editMode &&
category?.permalink &&
currentButtonAttributes?.url &&
category.permalink !== currentButtonAttributes.url
) {
updateBlockAttributes( {
...currentButtonAttributes,
url: category.permalink,
} );
this.setState( { doUrlUpdate: false } );
}
}
triggerUrlUpdate = () => {
this.setState( { doUrlUpdate: true } );
};
render() {
return (
<ProductComponent
triggerUrlUpdate={ this.triggerUrlUpdate }
{ ...this.props }
/>
);
}
}
return WrappedComponent;
}, 'withUpdateButtonAttributes' ),
] )( FeaturedCategory );

View File

@ -1,23 +0,0 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import Block from './block';
export const Edit = ( props: unknown ): JSX.Element => {
const blockProps = useBlockProps();
// The useBlockProps function returns the style with the `color`.
// We need to remove it to avoid the block to be styled with the color.
const { color, ...styles } = blockProps.style;
return (
<div { ...blockProps } style={ styles }>
<Block { ...props } />
</div>
);
};

View File

@ -1,15 +0,0 @@
.wc-block-featured-category {
background-color: inherit;
.components-resizable-box__handle {
z-index: 10;
}
.components-placeholder__label svg {
fill: currentColor;
margin-right: 1ch;
}
}
.wc-block-featured-category__selection {
width: 100%;
}

View File

@ -1,90 +0,0 @@
/**
* External dependencies
*/
import { InnerBlocks } from '@wordpress/block-editor';
import { registerBlockType } from '@wordpress/blocks';
import { getSetting } from '@woocommerce/settings';
import { folderStarred } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import { example } from './example';
import { Edit } from './edit';
import metadata from './block.json';
/**
* Register and run the "Featured Category" block.
*/
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ folderStarred }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
/**
* A minimum height for the block.
*
* Note: if padding is increased, this way the inner content will never
* overflow, but instead will resize the container.
*
* It was decided to change this to make this block more in line with
* the Cover block.
*/
minHeight: {
type: 'number',
default: getSetting( 'default_height', 500 ),
},
},
supports: {
...metadata.supports,
color: {
background: true,
text: true,
...( isFeaturePluginBuild() && {
__experimentalDuotone:
'.wc-block-featured-category__background-image',
} ),
},
spacing: {
padding: true,
...( isFeaturePluginBuild() && {
__experimentalDefaultControls: {
padding: true,
},
__experimentalSkipSerialization: true,
} ),
},
...( isFeaturePluginBuild() && {
__experimentalBorder: {
color: true,
radius: true,
width: true,
__experimentalSkipSerialization: true,
},
} ),
},
example,
/**
* Renders and manages the block.
*
* @param {Object} props Props to pass to block.
*/
edit: Edit,
/**
* Block content is rendered in PHP, not via save function.
*/
save: () => {
return <InnerBlocks.Content />;
},
} );

View File

@ -1,186 +0,0 @@
.wp-block-woocommerce-featured-category {
background-color: transparent;
border-color: transparent;
color: #fff;
box-sizing: border-box;
.components-resizable-box__container {
position: absolute !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 50px;
&:not(.is-resizing) {
height: auto !important;
}
}
// Applying image edits
.is-applying {
.components-spinner {
position: absolute;
top: 50%;
left: 50%;
margin-top: -9px;
margin-left: -9px;
}
img {
opacity: 0.3;
}
}
}
.wc-block-featured-category {
align-content: center;
align-items: center;
background-size: cover;
background-position: center center;
display: flex;
flex-wrap: wrap;
justify-content: center;
margin: 0;
overflow: hidden;
position: relative;
width: 100%;
.wc-block-featured-category__wrapper {
align-content: center;
align-items: center;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
height: 100%;
justify-content: center;
overflow: hidden;
}
&.has-left-content {
justify-content: flex-start;
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price {
margin-left: 0;
text-align: left;
}
}
&.has-right-content {
justify-content: flex-end;
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price {
margin-right: 0;
text-align: right;
}
}
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price {
color: $white;
line-height: 1.25;
margin-bottom: 0;
text-align: center;
a,
a:hover,
a:focus,
a:active {
color: $white;
}
}
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price,
.wc-block-featured-category__link {
color: inherit;
width: 100%;
padding: 0 48px 16px 48px;
z-index: 1;
}
.wc-block-featured-category__title {
margin-top: 0;
div {
color: inherit;
}
&::before {
display: none;
}
}
.wc-block-featured-category__description {
color: inherit;
p {
margin: 0;
}
}
.wc-block-featured-category__background-image {
@include absolute-stretch();
object-fit: none;
}
.wp-block-button.aligncenter {
text-align: center;
}
&.has-background-dim {
.wc-block-featured-category__overlay::before {
background: inherit;
border-radius: inherit;
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
opacity: 0.5;
z-index: 1;
}
}
@for $i from 1 through 10 {
&.has-background-dim.has-background-dim-#{ $i * 10 } {
.wc-block-featured-category__overlay::before {
opacity: $i * 0.1;
}
}
}
// Apply max-width to floated items that have no intrinsic width
&.alignleft,
&.alignright {
max-width: $content-width * 0.5;
width: 100%;
}
// Using flexbox without an assigned height property breaks vertical center alignment in IE11.
// Appending an empty ::after element tricks IE11 into giving the cover image an implicit height, which sidesteps this issue.
&::after {
display: block;
content: "";
font-size: 0;
min-height: inherit;
// IE doesn't support flex so omit that.
@supports (position: sticky) {
content: none;
}
}
// Aligned cover blocks should not use our global alignment rules
&.aligncenter,
&.alignleft,
&.alignright {
display: flex;
}
}

View File

@ -1,73 +0,0 @@
/**
* External dependencies
*/
import { isObject } from 'lodash';
/**
* Get the src from a category object, unless null (no image).
*
* @param {Object|null} category A product category object from the API.
* @return {string} The src of the category image.
*/
function getCategoryImageSrc( category ) {
if ( category && isObject( category.image ) ) {
return category.image.src;
}
return '';
}
/**
* Get the attachment ID from a category object, unless null (no image).
*
* @param {Object|null} category A product category object from the API.
* @return {number} The id of the category image.
*/
function getCategoryImageId( category ) {
if ( category && isObject( category.image ) ) {
return category.image.id;
}
return 0;
}
/**
* Generate a style object given either a product category image from the API or URL to an image.
*
* @param {string} url An image URL.
* @return {Object} A style object with a backgroundImage set (if a valid image is provided).
*/
function getBackgroundImageStyles( url ) {
if ( url ) {
return { backgroundImage: `url(${ url })` };
}
return {};
}
/**
* Convert the selected ratio to the correct background class.
*
* @param {number} ratio Selected opacity from 0 to 100.
* @return {string} The class name, if applicable (not used for ratio 0 or 50).
*/
function dimRatioToClass( ratio ) {
return ratio === 0 || ratio === 50
? null
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
}
export {
getCategoryImageSrc,
getCategoryImageId,
getBackgroundImageStyles,
dimRatioToClass,
};
export function calculateBackgroundImagePosition( coords ) {
if ( ! coords ) return {};
const x = Math.round( coords.x * 100 );
const y = Math.round( coords.y * 100 );
return {
objectPosition: `${ x }% ${ y }%`,
};
}

View File

@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls as BlockControlsWrapper,
MediaReplaceFlow,
} from '@wordpress/block-editor';
import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
import { crop } from '@wordpress/icons';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
/**
* Internal dependencies
*/
import { useBackgroundImage } from './use-background-image';
export const BlockControls = ( {
backgroundImageId,
backgroundImageSrc,
contentAlign,
cropLabel,
editLabel,
editMode,
isEditingImage,
mediaSrc,
setAttributes,
setIsEditingImage,
} ) => {
return (
<BlockControlsWrapper>
<AlignmentToolbar
value={ contentAlign }
onChange={ ( nextAlign ) => {
setAttributes( { contentAlign: nextAlign } );
} }
/>
<ToolbarGroup>
{ backgroundImageSrc && ! isEditingImage && (
<ToolbarButton
onClick={ () => setIsEditingImage( true ) }
icon={ crop }
label={ cropLabel }
/>
) }
<MediaReplaceFlow
mediaId={ backgroundImageId }
mediaURL={ mediaSrc }
accept="image/*"
onSelect={ ( media ) => {
setAttributes( {
mediaId: media.id,
mediaSrc: media.url,
} );
} }
allowedTypes={ [ 'image' ] }
/>
{ backgroundImageId && mediaSrc ? (
<TextToolbarButton
onClick={ () =>
setAttributes( { mediaId: 0, mediaSrc: '' } )
}
>
{ __( 'Reset', 'woo-gutenberg-products-block' ) }
</TextToolbarButton>
) : null }
</ToolbarGroup>
<ToolbarGroup
controls={ [
{
icon: 'edit',
title: editLabel,
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControlsWrapper>
);
};
export const withBlockControls = ( { cropLabel, editLabel } ) => (
Component
) => ( props ) => {
const [ isEditingImage, setIsEditingImage ] = props.useEditingImage;
const { attributes, category, name, product, setAttributes } = props;
const { contentAlign, editMode, mediaId, mediaSrc } = attributes;
const item = category || product;
const { backgroundImageId, backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
return (
<>
<BlockControls
backgroundImageId={ backgroundImageId }
backgroundImageSrc={ backgroundImageSrc }
contentAlign={ contentAlign }
cropLabel={ cropLabel }
editLabel={ editLabel }
editMode={ editMode }
isEditingImage={ isEditingImage }
mediaSrc={ mediaSrc }
setAttributes={ setAttributes }
setIsEditingImage={ setIsEditingImage }
/>
<Component { ...props } />
</>
);
};

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { RichText, InnerBlocks } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
export const CallToAction = ( { itemId, linkText, permalink } ) => {
const buttonClasses = classnames(
'wp-block-button__link',
'is-style-fill'
);
const buttonStyle = {
backgroundColor: 'vivid-green-cyan',
borderRadius: '5px',
};
const wrapperStyle = {
width: '100%',
};
return itemId === 'preview' ? (
<div className="wp-block-button aligncenter" style={ wrapperStyle }>
<RichText.Content
tagName="a"
className={ buttonClasses }
href={ permalink }
title={ linkText }
style={ buttonStyle }
value={ linkText }
target={ permalink }
/>
</div>
) : (
<InnerBlocks
template={ [
[
'core/buttons',
{
layout: { type: 'flex', justifyContent: 'center' },
},
[
[
'core/button',
{
text: __(
'Shop now',
'woo-gutenberg-products-block'
),
url: permalink,
},
],
],
],
] }
templateLock="all"
/>
);
};

View File

@ -0,0 +1,9 @@
export const DEFAULT_EDITOR_SIZE = {
height: 500,
width: 500,
};
export const BLOCK_NAMES = {
featuredCategory: 'woocommerce/featured-category',
featuredProduct: 'woocommerce/featured-product',
};

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { useState } from 'react';
import { ResizableBox } from '@wordpress/components';
/**
* Internal dependencies
*/
import { useThrottle } from '../../utils/useThrottle';
export const ConstrainedResizable = ( {
className = '',
onResize,
...props
} ) => {
const [ isResizing, setIsResizing ] = useState( false );
const classNames = classnames( className, {
'is-resizing': isResizing,
} );
const throttledResize = useThrottle(
( event, direction, elt ) => {
if ( ! isResizing ) setIsResizing( true );
onResize( event, direction, elt );
},
50,
{ leading: true }
);
return (
<ResizableBox
className={ classNames }
enable={ { bottom: true } }
onResize={ throttledResize }
onResizeStop={ ( ...args ) => {
onResize( ...args );
setIsResizing( false );
} }
{ ...props }
/>
);
};

View File

@ -0,0 +1,21 @@
/**
* External dependencies
*/
import { FunctionComponent } from 'react';
import { useBlockProps } from '@wordpress/block-editor';
export function Edit< T >( Block: FunctionComponent< T > ) {
return function WithBlock( props: T ): JSX.Element {
const blockProps = useBlockProps();
// The useBlockProps function returns the style with the `color`.
// We need to remove it to avoid the block to be styled with the color.
const { color, ...styles } = blockProps.style;
return (
<div { ...blockProps } style={ styles }>
<Block { ...props } />
</div>
);
};
}

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { withCategory } from '@woocommerce/block-hocs';
import { withSpokenMessages } from '@wordpress/components';
import { compose, createHigherOrderComponent } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { folderStarred } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import { withBlockControls } from '../block-controls';
import { withImageEditor } from '../image-editor';
import { withInspectorControls } from '../inspector-controls';
import { withApiError } from '../with-api-error';
import { withEditMode } from '../with-edit-mode';
import { withEditingImage } from '../with-editing-image';
import { withFeaturedItem } from '../with-featured-item';
const GENERIC_CONFIG = {
icon: folderStarred,
label: __( 'Featured Category', 'woo-gutenberg-products-block' ),
};
const BLOCK_CONTROL_CONFIG = {
cropLabel: __( 'Edit category image', 'woo-gutenberg-products-block' ),
editLabel: __( 'Edit selected category', 'woo-gutenberg-products-block' ),
};
const CONTENT_CONFIG = {
...GENERIC_CONFIG,
emptyMessage: __(
'No product category is selected.',
'woo-gutenberg-products-block'
),
};
const EDIT_MODE_CONFIG = {
...GENERIC_CONFIG,
description: __(
'Visually highlight a product category and encourage prompt action.',
'woo-gutenberg-products-block'
),
editLabel: __(
'Showing Featured Product block preview.',
'woo-gutenberg-products-block'
),
};
export default compose( [
withCategory,
withSpokenMessages,
withSelect( ( select, { clientId }, { dispatch } ) => {
const Block = select( 'core/block-editor' ).getBlock( clientId );
const buttonBlockId = Block?.innerBlocks[ 0 ]?.clientId || '';
const currentButtonAttributes =
Block?.innerBlocks[ 0 ]?.attributes || {};
const updateBlockAttributes = ( attributes ) => {
if ( buttonBlockId ) {
dispatch( 'core/block-editor' ).updateBlockAttributes(
buttonBlockId,
attributes
);
}
};
return { updateBlockAttributes, currentButtonAttributes };
} ),
createHigherOrderComponent( ( ProductComponent ) => {
class WrappedComponent extends Component {
state = {
doUrlUpdate: false,
};
componentDidUpdate() {
const {
attributes,
updateBlockAttributes,
currentButtonAttributes,
category,
} = this.props;
if (
this.state.doUrlUpdate &&
! attributes.editMode &&
category?.permalink &&
currentButtonAttributes?.url &&
category.permalink !== currentButtonAttributes.url
) {
updateBlockAttributes( {
...currentButtonAttributes,
url: category.permalink,
} );
this.setState( { doUrlUpdate: false } );
}
}
triggerUrlUpdate = () => {
this.setState( { doUrlUpdate: true } );
};
render() {
return (
<ProductComponent
triggerUrlUpdate={ this.triggerUrlUpdate }
{ ...this.props }
/>
);
}
}
return WrappedComponent;
}, 'withUpdateButtonAttributes' ),
withEditingImage,
withEditMode( EDIT_MODE_CONFIG ),
withFeaturedItem( CONTENT_CONFIG ),
withApiError,
withImageEditor,
withInspectorControls,
withBlockControls( BLOCK_CONTROL_CONFIG ),
] )( () => <></> );

View File

@ -0,0 +1,6 @@
@import "../style";
.wp-block-woocommerce-featured-category {
@extend %with-media-controls;
@extend %with-resizable-box;
}

View File

@ -1,18 +1,10 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { previewCategories } from '@woocommerce/resource-previews';
export const example = {
attributes: {
alt: '',
contentAlign: 'center',
dimRatio: 50,
editMode: false,
height: getSetting( 'default_height', 500 ),
mediaSrc: '',
showDesc: true,
categoryId: 'preview',
previewCategory: previewCategories[ 0 ],
},

View File

@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { folderStarred } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import Block from './block';
import metadata from './block.json';
import { register } from '../register';
import { example } from './example';
register( Block, example, metadata, {
icon: {
src: (
<Icon
icon={ folderStarred }
className="wc-block-editor-components-block-icon"
/>
),
},
} );

View File

@ -0,0 +1,9 @@
@import "../style";
.wp-block-woocommerce-featured-category {
@extend %wp-block-featured-item;
}
.wc-block-featured-category {
@include wc-block-featured-item();
}

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { isObject } from 'lodash';
/**
* Get the src from a category object, unless null (no image).
*
* @param {Object|null} category A product category object from the API.
* @return {string} The src of the category image.
*/
function getCategoryImageSrc( category ) {
if ( category && isObject( category.image ) ) {
return category.image.src;
}
return '';
}
/**
* Get the attachment ID from a category object, unless null (no image).
*
* @param {Object|null} category A product category object from the API.
* @return {number} The id of the category image.
*/
function getCategoryImageId( category ) {
if ( category && isObject( category.image ) ) {
return category.image.id;
}
return 0;
}
export { getCategoryImageSrc, getCategoryImageId };

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { withProduct } from '@woocommerce/block-hocs';
import { withSpokenMessages } from '@wordpress/components';
import { compose, createHigherOrderComponent } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { withBlockControls } from '../block-controls';
import { withImageEditor } from '../image-editor';
import { withInspectorControls } from '../inspector-controls';
import { withApiError } from '../with-api-error';
import { withEditMode } from '../with-edit-mode';
import { withEditingImage } from '../with-editing-image';
import { withFeaturedItem } from '../with-featured-item';
const GENERIC_CONFIG = {
icon: starEmpty,
label: __( 'Featured Product', 'woo-gutenberg-products-block' ),
};
const BLOCK_CONTROL_CONFIG = {
cropLabel: __( 'Edit product image', 'woo-gutenberg-products-block' ),
editLabel: __( 'Edit selected product', 'woo-gutenberg-products-block' ),
};
const CONTENT_CONFIG = {
...GENERIC_CONFIG,
emptyMessage: __(
'No product is selected.',
'woo-gutenberg-products-block'
),
};
const EDIT_MODE_CONFIG = {
...GENERIC_CONFIG,
description: __(
'Visually highlight a product or variation and encourage prompt action',
'woo-gutenberg-products-block'
),
editLabel: __(
'Showing Featured Product block preview.',
'woo-gutenberg-products-block'
),
};
export default compose( [
withProduct,
withSpokenMessages,
withSelect( ( select, { clientId }, { dispatch } ) => {
const Block = select( 'core/block-editor' ).getBlock( clientId );
const buttonBlockId = Block?.innerBlocks[ 0 ]?.clientId || '';
const currentButtonAttributes =
Block?.innerBlocks[ 0 ]?.attributes || {};
const updateBlockAttributes = ( attributes ) => {
if ( buttonBlockId ) {
dispatch( 'core/block-editor' ).updateBlockAttributes(
buttonBlockId,
attributes
);
}
};
return { updateBlockAttributes, currentButtonAttributes };
} ),
createHigherOrderComponent( ( ProductComponent ) => {
class WrappedComponent extends Component {
state = {
doUrlUpdate: false,
};
componentDidUpdate() {
const {
attributes,
updateBlockAttributes,
currentButtonAttributes,
product,
} = this.props;
if (
this.state.doUrlUpdate &&
! attributes.editMode &&
product?.permalink &&
currentButtonAttributes?.url &&
product.permalink !== currentButtonAttributes.url
) {
updateBlockAttributes( {
...currentButtonAttributes,
url: product.permalink,
} );
this.setState( { doUrlUpdate: false } );
}
}
triggerUrlUpdate = () => {
this.setState( { doUrlUpdate: true } );
};
render() {
return (
<ProductComponent
triggerUrlUpdate={ this.triggerUrlUpdate }
{ ...this.props }
/>
);
}
}
return WrappedComponent;
}, 'withUpdateButtonAttributes' ),
withEditingImage,
withEditMode( EDIT_MODE_CONFIG ),
withFeaturedItem( CONTENT_CONFIG ),
withApiError,
withImageEditor,
withInspectorControls,
withBlockControls( BLOCK_CONTROL_CONFIG ),
] )( () => <></> );

View File

@ -0,0 +1,10 @@
@import "../style";
.wp-block-woocommerce-featured-product {
@extend %with-media-controls;
@extend %with-resizable-box;
&__message {
margin-bottom: 16px;
}
}

View File

@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { previewProducts } from '@woocommerce/resource-previews';
export const example = {
attributes: {
productId: 'preview',
previewProduct: previewProducts[ 0 ],
},
};

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { Icon, starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import Block from './block';
import { register } from '../register';
import { example } from './example';
import metadata from './block.json';
register( Block, example, metadata, {
icon: {
src: (
<Icon
icon={ starEmpty }
className="wc-block-editor-components-block-icon"
/>
),
},
} );

View File

@ -0,0 +1,32 @@
@import "../style";
.wp-block-woocommerce-featured-product {
@extend %wp-block-featured-item;
background-color: transparent;
}
.wc-block-featured-product {
@include wc-block-featured-item();
.wc-block-featured-product__title,
.wc-block-featured-product__variation {
margin-top: 0;
border: 0;
&::before {
display: none;
}
}
.wc-block-featured-product__variation {
font-style: italic;
padding-top: 0;
}
.wc-block-featured-product__description {
p {
margin: 0;
line-height: 1.5;
}
}
}

View File

@ -0,0 +1,85 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import {
__experimentalImageEditingProvider as ImageEditingProvider,
__experimentalImageEditor as GutenbergImageEditor,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import { BLOCK_NAMES, DEFAULT_EDITOR_SIZE } from './constants';
import { useBackgroundImage } from './use-background-image';
export const ImageEditor = ( {
backgroundImageId,
backgroundImageSize,
backgroundImageSrc,
isEditingImage,
setAttributes,
setIsEditingImage,
} ) => {
return (
<>
<ImageEditingProvider
id={ backgroundImageId }
url={ backgroundImageSrc }
naturalHeight={
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
}
naturalWidth={
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
}
onSaveImage={ ( { id, url } ) => {
setAttributes( { mediaId: id, mediaSrc: url } );
} }
isEditing={ isEditingImage }
onFinishEditing={ () => setIsEditingImage( false ) }
>
<GutenbergImageEditor
url={ backgroundImageSrc }
height={
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
}
width={
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
}
/>
</ImageEditingProvider>
</>
);
};
export const withImageEditor = ( Component ) => ( props ) => {
const [ isEditingImage, setIsEditingImage ] = props.useEditingImage;
const { attributes, backgroundImageSize, name, setAttributes } = props;
const { mediaId, mediaSrc } = attributes;
const item =
name === BLOCK_NAMES.featuredProduct ? props.product : props.category;
const { backgroundImageId, backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
if ( isEditingImage ) {
return (
<ImageEditor
backgroundImageId={ backgroundImageId }
backgroundImageSize={ backgroundImageSize }
backgroundImageSrc={ backgroundImageSrc }
isEditingImage={ isEditingImage }
setAttributes={ setAttributes }
setIsEditingImage={ setIsEditingImage }
/>
);
}
return <Component { ...props } />;
};

View File

@ -0,0 +1,292 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
InspectorControls as GutenbergInspectorControls,
__experimentalPanelColorGradientSettings as PanelColorGradientSettings,
__experimentalUseGradient as useGradient,
} from '@wordpress/block-editor';
import {
FocalPointPicker,
PanelBody,
RangeControl,
ToggleControl,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
TextareaControl,
ExternalLink,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useBackgroundImage } from './use-background-image';
import { BLOCK_NAMES } from './constants';
export const InspectorControls = ( {
alt,
backgroundImageSrc,
contentPanel,
dimRatio,
focalPoint = { x: 0.5, y: 0.5 },
hasParallax,
isRepeated,
imageFit,
overlayColor,
overlayGradient,
setAttributes,
setGradient,
showDesc,
} ) => {
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
// so we need to check if it exists before using it.
const focalPointPickerExists = typeof FocalPointPicker === 'function';
const isImgElement = ! isRepeated && ! hasParallax;
return (
<GutenbergInspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woo-gutenberg-products-block' ) }
>
<ToggleControl
label={ __(
'Show description',
'woo-gutenberg-products-block'
) }
checked={ showDesc }
onChange={ () => setAttributes( { showDesc: ! showDesc } ) }
/>
{ contentPanel }
</PanelBody>
{ !! backgroundImageSrc && (
<>
{ focalPointPickerExists && (
<PanelBody
title={ __(
'Media settings',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Fixed background',
'woo-gutenberg-products-block'
) }
checked={ hasParallax }
onChange={ () => {
setAttributes( {
hasParallax: ! hasParallax,
} );
} }
/>
<ToggleControl
label={ __(
'Repeated background',
'woo-gutenberg-products-block'
) }
checked={ isRepeated }
onChange={ () => {
setAttributes( {
isRepeated: ! isRepeated,
} );
} }
/>
{ ! isRepeated && (
<ToggleGroupControl
help={
<>
<p>
{ __(
'Choose “Cover” if you want the image to scale automatically to always fit its container.',
'woo-gutenberg-products-block'
) }
</p>
<p>
{ __(
'Note: by choosing “Cover” you will lose the ability to freely move the focal point precisely.',
'woo-gutenberg-products-block'
) }
</p>
</>
}
label={ __(
'Image fit',
'woo-gutenberg-products-block'
) }
value={ imageFit }
onChange={ ( value ) =>
setAttributes( {
imageFit: value,
} )
}
>
<ToggleGroupControlOption
label={ __(
'None',
'woo-gutenberg-products-block'
) }
value="none"
/>
<ToggleGroupControlOption
/* translators: "Cover" is a verb that indicates an image covering the entire container. */
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
value="cover"
/>
</ToggleGroupControl>
) }
<FocalPointPicker
label={ __(
'Focal Point Picker',
'woo-gutenberg-products-block'
) }
url={ backgroundImageSrc }
value={ focalPoint }
onChange={ ( value ) =>
setAttributes( {
focalPoint: value,
} )
}
/>
{ isImgElement && (
<TextareaControl
label={ __(
'Alt text (alternative text)',
'woo-gutenberg-products-block'
) }
value={ alt }
onChange={ ( value ) => {
setAttributes( { alt: value } );
} }
help={
<>
<ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree">
{ __(
'Describe the purpose of the image',
'woo-gutenberg-products-block'
) }
</ExternalLink>
</>
}
/>
) }
</PanelBody>
) }
<PanelColorGradientSettings
__experimentalHasMultipleOrigins
__experimentalIsRenderedInSidebar
title={ __(
'Overlay',
'woo-gutenberg-products-block'
) }
initialOpen={ true }
settings={ [
{
colorValue: overlayColor,
gradientValue: overlayGradient,
onColorChange: ( value ) =>
setAttributes( { overlayColor: value } ),
onGradientChange: ( value ) => {
setGradient( value );
setAttributes( {
overlayGradient: value,
} );
},
label: __(
'Color',
'woo-gutenberg-products-block'
),
},
] }
>
<RangeControl
label={ __(
'Opacity',
'woo-gutenberg-products-block'
) }
value={ dimRatio }
onChange={ ( value ) =>
setAttributes( { dimRatio: value } )
}
min={ 0 }
max={ 100 }
step={ 10 }
required
/>
</PanelColorGradientSettings>
</>
) }
</GutenbergInspectorControls>
);
};
export const withInspectorControls = ( Component ) => ( props ) => {
const { attributes, name, setAttributes } = props;
const {
alt,
dimRatio,
focalPoint,
hasParallax,
isRepeated,
imageFit,
mediaId,
mediaSrc,
overlayColor,
overlayGradient,
showDesc,
showPrice,
} = attributes;
const item =
name === BLOCK_NAMES.featuredProduct ? props.product : props.category;
const { setGradient } = useGradient( {
gradientAttribute: 'overlayGradient',
customGradientAttribute: 'overlayGradient',
} );
const { backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
const contentPanel = name === BLOCK_NAMES.featuredProduct && (
<ToggleControl
label={ __( 'Show price', 'woo-gutenberg-products-block' ) }
checked={ showPrice }
onChange={ () =>
setAttributes( {
showPrice: ! showPrice,
} )
}
/>
);
return (
<>
<InspectorControls
alt={ alt }
backgroundImageSrc={ backgroundImageSrc }
contentPanel={ contentPanel }
dimRatio={ dimRatio }
focalPoint={ focalPoint }
hasParallax={ hasParallax }
isRepeated={ isRepeated }
imageFit={ imageFit }
overlayColor={ overlayColor }
overlayGradient={ overlayGradient }
setAttributes={ setAttributes }
setGradient={ setGradient }
showDesc={ showDesc }
/>
<Component { ...props } />
</>
);
};

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import { FunctionComponent } from 'react';
import { InnerBlocks } from '@wordpress/block-editor';
import { BlockConfiguration, registerBlockType } from '@wordpress/blocks';
import { getSetting } from '@woocommerce/settings';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { Edit } from './edit';
export function register(
Block: FunctionComponent,
example: { attributes: Record< string, unknown > },
metadata: BlockConfiguration,
settings: Partial< BlockConfiguration >
): void {
const DEFAULT_SETTINGS = {
attributes: {
...metadata.attributes,
/**
* A minimum height for the block.
*
* Note: if padding is increased, this way the inner content will never
* overflow, but instead will resize the container.
*
* It was decided to change this to make this block more in line with
* the Cover block.
*/
minHeight: {
type: 'number',
default: getSetting( 'default_height', 500 ),
},
},
supports: {
...metadata.supports,
color: {
background: metadata.supports?.color?.background,
text: metadata.supports?.color?.text,
...( isFeaturePluginBuild() && {
__experimentalDuotone:
metadata.supports?.color?.__experimentalDuotone,
} ),
},
spacing: {
padding: metadata.supports?.spacing?.padding,
...( isFeaturePluginBuild() && {
__experimentalDefaultControls: {
padding:
metadata.supports?.spacing
?.__experimentalDefaultControls,
},
__experimentalSkipSerialization:
metadata.supports?.spacing
?.__experimentalSkipSerialization,
} ),
},
...( isFeaturePluginBuild() && {
__experimentalBorder: metadata?.supports?.__experimentalBorder,
} ),
},
};
const DEFAULT_EXAMPLE = {
attributes: {
alt: '',
contentAlign: 'center',
dimRatio: 50,
editMode: false,
hasParallax: false,
isRepeated: false,
height: getSetting( 'default_height', 500 ),
mediaSrc: '',
overlayColor: '#000000',
showDesc: true,
},
};
registerBlockType( metadata, {
...DEFAULT_SETTINGS,
example: {
...DEFAULT_EXAMPLE,
example,
},
/**
* Renders and manages the block.
*
* @param {Object} props Props to pass to block.
*/
edit: Edit( Block ),
/**
* Block content is rendered in PHP, not via save function.
*/
save: () => <InnerBlocks.Content />,
...settings,
} );
}

View File

@ -1,27 +1,12 @@
.wp-block-woocommerce-featured-product {
background-color: transparent;
border-color: transparent;
color: #fff;
box-sizing: border-box;
@mixin with-content-selection {
background-color: inherit;
.components-resizable-box__container {
position: absolute !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 50px;
&:not(.is-resizing) {
height: auto !important;
}
}
&.is-repeated {
background-repeat: repeat;
background-size: auto;
&__selection {
width: 100%;
}
}
%with-media-controls {
// Applying image edits
.is-applying {
.components-spinner {
@ -38,11 +23,42 @@
}
}
.wc-block-featured-product {
%with-resizable-box {
.components-resizable-box__container {
position: absolute !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 50px;
&:not(.is-resizing) {
height: auto !important;
}
}
.components-resizable-box__handle {
z-index: 10;
}
}
%wp-block-featured-item {
background-color: transparent;
border-color: transparent;
color: #fff;
box-sizing: border-box;
}
@mixin wc-block-featured-item {
$block: &;
@include with-background-dim();
@include with-content-selection();
align-content: center;
align-items: center;
background-size: cover;
background-position: center center;
background-size: cover;
display: flex;
flex-wrap: wrap;
justify-content: center;
@ -51,25 +67,13 @@
position: relative;
width: 100%;
.wc-block-featured-product__wrapper {
align-content: center;
align-items: center;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
height: 100%;
width: 100%;
justify-content: center;
overflow: hidden;
}
&.has-left-content {
justify-content: flex-start;
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
#{$block}__description,
#{$block}__price,
#{$block}__title,
#{$block}__variation {
margin-left: 0;
text-align: left;
}
@ -78,23 +82,27 @@
&.has-right-content {
justify-content: flex-end;
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
#{$block}__description,
#{$block}__price,
#{$block}__title,
#{$block}__variation {
margin-right: 0;
text-align: right;
}
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
&.is-repeated {
background-repeat: repeat;
background-size: auto;
}
&__description,
&__price,
&__title,
&__variation {
line-height: 1.25;
margin-bottom: 0;
text-align: center;
color: inherit;
a,
a:hover,
@ -104,39 +112,18 @@
}
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price,
.wc-block-featured-product__link {
&__description,
&__link,
&__price,
&__title,
&__variation {
color: inherit;
width: 100%;
padding: 16px 48px 0 48px;
padding: 0 48px 16px 48px;
z-index: 1;
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation {
margin-top: 0;
border: 0;
&::before {
display: none;
}
}
.wc-block-featured-product__variation {
font-style: italic;
padding-top: 0;
}
.wc-block-featured-product__description {
p {
margin: 0;
line-height: 1.5;
}
}
.wc-block-featured-product__background-image {
& &__background-image {
@include absolute-stretch();
object-fit: none;
@ -157,57 +144,39 @@
}
}
&__description {
color: inherit;
p {
margin: 0;
}
}
&__title {
margin-top: 0;
div {
color: inherit;
}
&::before {
display: none;
}
}
&__wrapper {
align-content: center;
align-items: center;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
justify-content: center;
overflow: hidden;
width: 100%;
height: 100%;
}
.wp-block-button.aligncenter {
text-align: center;
}
&.has-background-dim {
.wc-block-featured-product__overlay::before {
background: inherit;
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
opacity: 0.5;
z-index: 1;
}
}
@for $i from 1 through 10 {
&.has-background-dim.has-background-dim-#{ $i * 10 } {
.wc-block-featured-product__overlay::before {
opacity: $i * 0.1;
}
}
}
// Apply max-width to floated items that have no intrinsic width
&.alignleft,
&.alignright {
max-width: $content-width * 0.5;
width: 100%;
}
// Using flexbox without an assigned height property breaks vertical center alignment in IE11.
// Appending an empty ::after element tricks IE11 into giving the cover image an implicit height, which sidesteps this issue.
&::after {
display: block;
content: "";
font-size: 0;
min-height: inherit;
// IE doesn't support flex so omit that.
@supports (position: sticky) {
content: none;
}
}
// Aligned cover blocks should not use our global alignment rules
&.aligncenter,
&.alignleft,
&.alignright {
display: flex;
}
}

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import {
getImageSrcFromProduct,
getImageIdFromProduct,
} from '@woocommerce/utils';
import { useEffect, useState } from 'react';
/**
* Internal dependencies
*/
import { BLOCK_NAMES } from './constants';
import {
getCategoryImageSrc,
getCategoryImageId,
} from './featured-category/utils';
export function useBackgroundImage( { blockName, item, mediaId, mediaSrc } ) {
const [ backgroundImageId, setBackgroundImageId ] = useState( 0 );
const [ backgroundImageSrc, setBackgroundImageSrc ] = useState( '' );
useEffect( () => {
if ( mediaId ) {
setBackgroundImageId( mediaId );
} else {
setBackgroundImageId(
blockName === BLOCK_NAMES.featuredProduct
? getImageIdFromProduct( item )
: getCategoryImageId( item )
);
}
}, [ blockName, item, mediaId ] );
useEffect( () => {
if ( mediaSrc ) {
setBackgroundImageSrc( mediaSrc );
} else {
setBackgroundImageSrc(
blockName === BLOCK_NAMES.featuredProduct
? getImageSrcFromProduct( item )
: getCategoryImageSrc( item )
);
}
}, [ blockName, item, mediaSrc ] );
return { backgroundImageId, backgroundImageSrc };
}

View File

@ -0,0 +1,87 @@
export function calculateBackgroundImagePosition( coords ) {
if ( ! coords ) return {};
return {
objectPosition: calculatePercentPositionFromCoordinates( coords ),
};
}
export function calculatePercentPositionFromCoordinates( coords ) {
if ( ! coords ) return '';
const x = Math.round( coords.x * 100 );
const y = Math.round( coords.y * 100 );
return `${ x }% ${ y }%`;
}
/**
* Generate the style object of the background image of the block.
*
* It outputs styles for either an `img` element or a `div` with a background,
* depending on what is needed.
*
* @param {Object} opts Options for the element.
* @param {Object} opts.focalPoint X and Y coordinates of the image.
* @param {'cover' | 'none'} opts.imageFit How to fit the image in the wrapper.
* @param {boolean} opts.isImgElement Whether the rendered background is an `img` element.
* @param {boolean} opts.isRepeated Whether the background is repeated (no effect if `isImgElement` is `true`).
* @param {string} opts.url The url of the image.
*
* @return {Object} A style object with a backgroundImage set (if a valid image is provided).
*/
export function getBackgroundImageStyles( {
focalPoint,
imageFit,
isImgElement,
isRepeated,
url,
} ) {
let styles = {};
if ( isImgElement ) {
styles = {
...styles,
...calculateBackgroundImagePosition( focalPoint ),
objectFit: imageFit,
};
} else {
styles = {
...styles,
...( url && {
backgroundImage: `url(${ url })`,
} ),
backgroundPosition: calculatePercentPositionFromCoordinates(
focalPoint
),
...( ! isRepeated && {
backgroundRepeat: 'no-repeat',
backgroundSize: imageFit === 'cover' ? imageFit : 'auto',
} ),
};
}
return styles;
}
/**
* Generates the CSS class prefix for scoping elements to a block.
*
* @param {string} blockName The name of the block.
* @return {string} The prefix for the HTML elements belonging to that block.
*/
export function getClassPrefixFromName( blockName ) {
return `wc-block-${ blockName.split( '/' )[ 1 ] }`;
}
/**
* Convert the selected ratio to the correct background class.
*
* @param {number} ratio Selected opacity from 0 to 100.
* @return {string} The class name, if applicable (not used for ratio 0 or 50).
*/
export function dimRatioToClass( ratio ) {
return ratio === 0 || ratio === 50
? null
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
}

View File

@ -0,0 +1,33 @@
/**
* External dependencies
*/
import ErrorPlaceholder from '@woocommerce/editor-components/error-placeholder';
/**
* Internal dependencies
*/
import { BLOCK_NAMES } from './constants';
import { getClassPrefixFromName } from './utils';
export const withApiError = ( Component ) => ( props ) => {
const { error, isLoading, name } = props;
const className = getClassPrefixFromName( name );
const onRetry =
name === BLOCK_NAMES.featuredCategory
? props.getCategory
: props.getProduct;
if ( error ) {
return (
<ErrorPlaceholder
className={ `${ className }-error` }
error={ error }
isLoading={ isLoading }
onRetry={ onRetry }
/>
);
}
return <Component { ...props } />;
};

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { Placeholder, Icon, Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import ProductCategoryControl from '@woocommerce/editor-components/product-category-control';
import ProductControl from '@woocommerce/editor-components/product-control';
/**
* Internal dependencies
*/
import { getClassPrefixFromName } from './utils';
import { BLOCK_NAMES } from './constants';
export const withEditMode = ( { description, editLabel, icon, label } ) => (
Component
) => ( props ) => {
const {
attributes,
debouncedSpeak,
name,
setAttributes,
triggerUrlUpdate = () => void null,
} = props;
const className = getClassPrefixFromName( name );
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak( editLabel );
};
if ( attributes.editMode ) {
return (
<Placeholder
icon={ <Icon icon={ icon } /> }
label={ label }
className={ className }
>
{ description }
<div className={ `${ className }__selection` }>
{ name === BLOCK_NAMES.featuredCategory && (
<ProductCategoryControl
selected={ [ attributes.categoryId ] }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
categoryId: id,
mediaId: 0,
mediaSrc: '',
} );
triggerUrlUpdate();
} }
isSingle
/>
) }
{ name === BLOCK_NAMES.featuredProduct && (
<ProductControl
selected={ attributes.productId || 0 }
showVariations
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
productId: id,
mediaId: 0,
mediaSrc: '',
} );
triggerUrlUpdate();
} }
/>
) }
<Button isPrimary onClick={ onDone }>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</div>
</Placeholder>
);
}
return <Component { ...props } />;
};

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { useEffect, useState } from 'react';
export const withEditingImage = ( Component ) => ( props ) => {
const [ isEditingImage, setIsEditingImage ] = useState( false );
const { isSelected } = props;
useEffect( () => {
setIsEditingImage( false );
}, [ isSelected ] );
return (
<Component
{ ...props }
useEditingImage={ [ isEditingImage, setIsEditingImage ] }
/>
);
};

View File

@ -0,0 +1,237 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { __experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles } from '@wordpress/block-editor';
import { Icon, Placeholder, Spinner } from '@wordpress/components';
import classnames from 'classnames';
import { isEmpty } from 'lodash';
import { useCallback, useState } from 'react';
/**
* Internal dependencies
*/
import { CallToAction } from './call-to-action';
import { ConstrainedResizable } from './constrained-resizable';
import { useBackgroundImage } from './use-background-image';
import {
dimRatioToClass,
getBackgroundImageStyles,
getClassPrefixFromName,
} from './utils';
export const withFeaturedItem = ( { emptyMessage, icon, label } ) => (
Component
) => ( props ) => {
const [ isEditingImage ] = props.useEditingImage;
const {
attributes,
category,
isLoading,
isSelected,
name,
product,
setAttributes,
} = props;
const { mediaId, mediaSrc } = attributes;
const item = category || product;
const [ backgroundImageSize, setBackgroundImageSize ] = useState( {} );
const { backgroundImageSrc } = useBackgroundImage( {
item,
mediaId,
mediaSrc,
blockName: name,
} );
const className = getClassPrefixFromName( name );
const onResize = useCallback(
( _event, _direction, elt ) => {
setAttributes( { minHeight: parseInt( elt.style.height, 10 ) } );
},
[ setAttributes ]
);
const renderButton = () => {
const { categoryId, linkText, productId } = attributes;
return (
<CallToAction
itemId={ categoryId || productId }
linkText={ linkText }
permalink={ ( category || product ).permalink }
/>
);
};
const renderNoItem = () => (
<Placeholder
className={ className }
icon={ <Icon icon={ icon } /> }
label={ label }
>
{ isLoading ? <Spinner /> : emptyMessage }
</Placeholder>
);
const renderItem = () => {
const {
contentAlign,
dimRatio,
focalPoint,
hasParallax,
isRepeated,
imageFit,
minHeight,
overlayColor,
overlayGradient,
showDesc,
showPrice,
style,
} = attributes;
const classes = classnames(
className,
{
'is-selected':
isSelected &&
attributes.categoryId !== 'preview' &&
attributes.productId !== 'preview',
'is-loading': ! item && isLoading,
'is-not-found': ! item && ! isLoading,
'has-background-dim': dimRatio !== 0,
'is-repeated': isRepeated,
},
dimRatioToClass( dimRatio ),
contentAlign !== 'center' && `has-${ contentAlign }-content`
);
const containerStyle = {
borderRadius: style?.border?.radius,
};
const wrapperStyle = {
...getSpacingClassesAndStyles( attributes ).style,
minHeight,
};
const isImgElement = ! isRepeated && ! hasParallax;
const backgroundImageStyle = getBackgroundImageStyles( {
focalPoint,
imageFit,
isImgElement,
isRepeated,
url: backgroundImageSrc,
} );
const overlayStyle = {
background: overlayGradient,
backgroundColor: overlayColor,
};
return (
<>
<ConstrainedResizable
enable={ { bottom: true } }
onResize={ onResize }
showHandle={ isSelected }
style={ { minHeight } }
/>
<div className={ classes } style={ containerStyle }>
<div
className={ `${ className }__wrapper` }
style={ wrapperStyle }
>
<div
className="background-dim__overlay"
style={ overlayStyle }
/>
{ backgroundImageSrc &&
( isImgElement ? (
<img
alt={ item.name }
className={ `${ className }__background-image` }
src={ backgroundImageSrc }
style={ backgroundImageStyle }
onLoad={ ( e ) => {
setBackgroundImageSize( {
height: e.target?.naturalHeight,
width: e.target?.naturalWidth,
} );
} }
/>
) : (
<div
className={ classnames(
`${ className }__background-image`,
{
'has-parallax': hasParallax,
}
) }
style={ backgroundImageStyle }
/>
) ) }
<h2
className={ `${ className }__title` }
dangerouslySetInnerHTML={ {
__html: item.name,
} }
/>
{ ! isEmpty( product?.variation ) && (
<h3
className={ `${ className }__variation` }
dangerouslySetInnerHTML={ {
__html: product.variation,
} }
/>
) }
{ showDesc && (
<div
className={ `${ className }__description` }
dangerouslySetInnerHTML={ {
__html:
category?.description ||
product?.short_description,
} }
/>
) }
{ showPrice && (
<div
className={ `${ className }__price` }
dangerouslySetInnerHTML={ {
__html: product.price_html,
} }
/>
) }
<div className={ `${ className }__link` }>
{ renderButton() }
</div>
</div>
</div>
</>
);
};
if ( isEditingImage ) {
return (
<Component
{ ...props }
backgroundImageSize={ backgroundImageSize }
/>
);
}
return (
<>
<Component
{ ...props }
backgroundImageSize={ backgroundImageSize }
/>
{ item ? renderItem() : renderNoItem() }
</>
);
};

View File

@ -1,851 +0,0 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { useCallback, useEffect, useState } from 'react';
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
InnerBlocks,
InspectorControls,
MediaReplaceFlow,
RichText,
__experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles,
__experimentalImageEditingProvider as ImageEditingProvider,
__experimentalImageEditor as ImageEditor,
__experimentalPanelColorGradientSettings as PanelColorGradientSettings,
__experimentalUseGradient as useGradient,
} from '@wordpress/block-editor';
import { withSelect } from '@wordpress/data';
import {
Button,
ExternalLink,
FocalPointPicker,
PanelBody,
Placeholder,
RangeControl,
ResizableBox,
Spinner,
TextareaControl,
ToggleControl,
ToolbarButton,
ToolbarGroup,
withSpokenMessages,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import { compose, createHigherOrderComponent } from '@wordpress/compose';
import { isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import ProductControl from '@woocommerce/editor-components/product-control';
import ErrorPlaceholder from '@woocommerce/editor-components/error-placeholder';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
import { withProduct } from '@woocommerce/block-hocs';
import { crop, Icon, starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import {
backgroundImageStyles,
calculateImagePosition,
dimRatioToClass,
} from './utils';
import {
getImageSrcFromProduct,
getImageIdFromProduct,
} from '../../utils/products';
import { useThrottle } from '../../utils/useThrottle';
const DEFAULT_EDITOR_SIZE = {
height: 500,
width: 500,
};
export const ConstrainedResizable = ( {
className = '',
onResize,
...props
} ) => {
const [ isResizing, setIsResizing ] = useState( false );
const classNames = classnames( className, {
'is-resizing': isResizing,
} );
const throttledResize = useThrottle(
( event, direction, elt ) => {
if ( ! isResizing ) setIsResizing( true );
onResize( event, direction, elt );
},
50,
{ leading: true }
);
return (
<ResizableBox
className={ classNames }
enable={ { bottom: true } }
onResize={ throttledResize }
onResizeStop={ ( ...args ) => {
onResize( ...args );
setIsResizing( false );
} }
{ ...props }
/>
);
};
/**
* Component to handle edit mode of "Featured Product".
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.attributes Incoming block attributes.
* @param {function(any):any} props.debouncedSpeak Function for delayed speak.
* @param {string} props.error Error message.
* @param {function(any):any} props.getProduct Function for getting the product.
* @param {boolean} props.isLoading Whether product is loading or not.
* @param {boolean} props.isSelected Whether block is selected or not.
* @param {Object} props.product Product object.
* @param {function(any):any} props.setAttributes Setter for attributes.
* @param {function():any} props.triggerUrlUpdate Function for triggering a url update for product.
*/
const FeaturedProduct = ( {
attributes,
debouncedSpeak,
error,
getProduct,
isLoading,
isSelected,
product,
setAttributes,
triggerUrlUpdate = () => void null,
} ) => {
const { mediaId, mediaSrc } = attributes;
const [ isEditingImage, setIsEditingImage ] = useState( false );
const [ backgroundImageSize, setBackgroundImageSize ] = useState( {} );
const { setGradient } = useGradient( {
gradientAttribute: 'overlayGradient',
customGradientAttribute: 'overlayGradient',
} );
const backgroundImageSrc = mediaSrc || getImageSrcFromProduct( product );
const backgroundImageId = mediaId || getImageIdFromProduct( product );
const onResize = useCallback(
( _event, _direction, elt ) => {
setAttributes( { minHeight: parseInt( elt.style.height, 10 ) } );
},
[ setAttributes ]
);
const renderApiError = () => (
<ErrorPlaceholder
className="wc-block-featured-product-error"
error={ error }
isLoading={ isLoading }
onRetry={ getProduct }
/>
);
useEffect( () => {
setIsEditingImage( false );
}, [ isSelected ] );
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Featured Product block preview.',
'woo-gutenberg-products-block'
)
);
};
return (
<>
{ getBlockControls() }
<Placeholder
icon={ <Icon icon={ starEmpty } /> }
label={ __(
'Featured Product',
'woo-gutenberg-products-block'
) }
className="wc-block-featured-product"
>
{ __(
'Visually highlight a product or variation and encourage prompt action',
'woo-gutenberg-products-block'
) }
<div className="wc-block-featured-product__selection">
<ProductControl
selected={ attributes.productId || 0 }
showVariations
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
productId: id,
mediaId: 0,
mediaSrc: '',
} );
triggerUrlUpdate();
} }
/>
<Button isPrimary onClick={ onDone }>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</div>
</Placeholder>
</>
);
};
const getBlockControls = () => {
const { contentAlign, editMode } = attributes;
return (
<BlockControls>
<AlignmentToolbar
value={ contentAlign }
onChange={ ( nextAlign ) => {
setAttributes( { contentAlign: nextAlign } );
} }
/>
<ToolbarGroup>
{ backgroundImageSrc && ! isEditingImage && (
<ToolbarButton
onClick={ () => setIsEditingImage( true ) }
icon={ crop }
label={ __(
'Edit product image',
'woo-gutenberg-products-block'
) }
/>
) }
<MediaReplaceFlow
mediaId={ backgroundImageId }
mediaURL={ mediaSrc }
accept="image/*"
onSelect={ ( media ) => {
setAttributes( {
mediaId: media.id,
mediaSrc: media.url,
} );
} }
allowedTypes={ [ 'image' ] }
/>
{ backgroundImageId && mediaSrc ? (
<TextToolbarButton
onClick={ () =>
setAttributes( { mediaId: 0, mediaSrc: '' } )
}
>
{ __( 'Reset', 'woo-gutenberg-products-block' ) }
</TextToolbarButton>
) : null }
</ToolbarGroup>
<ToolbarGroup
controls={ [
{
icon: 'edit',
title: __(
'Edit selected product',
'woo-gutenberg-products-block'
),
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
);
};
const getInspectorControls = () => {
const url = attributes.mediaSrc || getImageSrcFromProduct( product );
const {
focalPoint = { x: 0.5, y: 0.5 },
hasParallax,
isRepeated,
} = attributes;
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
// so we need to check if it exists before using it.
const focalPointPickerExists = typeof FocalPointPicker === 'function';
const isImgElement = ! isRepeated && ! hasParallax;
return (
<>
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Content',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Show description',
'woo-gutenberg-products-block'
) }
checked={ attributes.showDesc }
onChange={ () =>
setAttributes( {
showDesc: ! attributes.showDesc,
} )
}
/>
<ToggleControl
label={ __(
'Show price',
'woo-gutenberg-products-block'
) }
checked={ attributes.showPrice }
onChange={ () =>
setAttributes( {
showPrice: ! attributes.showPrice,
} )
}
/>
</PanelBody>
{ !! url && (
<>
{ focalPointPickerExists && (
<PanelBody
title={ __(
'Media settings',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Fixed background',
'woo-gutenberg-products-block'
) }
checked={ hasParallax }
onChange={ () => {
setAttributes( {
hasParallax: ! hasParallax,
} );
} }
/>
<ToggleControl
label={ __(
'Repeated background',
'woo-gutenberg-products-block'
) }
checked={ isRepeated }
onChange={ () => {
setAttributes( {
isRepeated: ! isRepeated,
} );
} }
/>
{ ! isRepeated && (
<ToggleGroupControl
help={
<>
<p>
{ __(
'Choose “Cover” if you want the image to scale automatically to always fit its container.',
'woo-gutenberg-products-block'
) }
</p>
<p>
{ __(
'Note: by choosing “Cover” you will lose the ability to freely move the focal point precisely.',
'woo-gutenberg-products-block'
) }
</p>
</>
}
label={ __(
'Image fit',
'woo-gutenberg-products-block'
) }
value={ attributes.imageFit }
onChange={ ( value ) =>
setAttributes( {
imageFit: value,
} )
}
>
<ToggleGroupControlOption
label={ __(
'None',
'woo-gutenberg-products-block'
) }
value="none"
/>
<ToggleGroupControlOption
/* translators: "Cover" is a verb that indicates an image covering the entire container. */
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
value="cover"
/>
</ToggleGroupControl>
) }
<FocalPointPicker
label={ __(
'Focal Point Picker',
'woo-gutenberg-products-block'
) }
url={ url }
value={ focalPoint }
onChange={ ( value ) =>
setAttributes( {
focalPoint: value,
} )
}
/>
{ isImgElement && (
<TextareaControl
label={ __(
'Alt text (alternative text)',
'woo-gutenberg-products-block'
) }
value={ attributes.alt }
onChange={ ( alt ) => {
setAttributes( { alt } );
} }
help={
<>
<ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree">
{ __(
'Describe the purpose of the image',
'woo-gutenberg-products-block'
) }
</ExternalLink>
{ __(
'Leaving it empty will use the product name.',
'woo-gutenberg-products-block'
) }
</>
}
/>
) }
</PanelBody>
) }
<PanelColorGradientSettings
__experimentalHasMultipleOrigins
__experimentalIsRenderedInSidebar
title={ __(
'Overlay',
'woo-gutenberg-products-block'
) }
initialOpen={ true }
settings={ [
{
colorValue: attributes.overlayColor,
gradientValue:
attributes.overlayGradient,
onColorChange: ( overlayColor ) =>
setAttributes( { overlayColor } ),
onGradientChange: (
overlayGradient
) => {
setGradient( overlayGradient );
setAttributes( {
overlayGradient,
} );
},
label: __(
'Color',
'woo-gutenberg-products-block'
),
},
] }
>
<RangeControl
label={ __(
'Opacity',
'woo-gutenberg-products-block'
) }
value={ attributes.dimRatio }
onChange={ ( dimRatio ) =>
setAttributes( { dimRatio } )
}
min={ 0 }
max={ 100 }
step={ 10 }
required
/>
</PanelColorGradientSettings>
</>
) }
</InspectorControls>
<InspectorControls __experimentalGroup="dimensions"></InspectorControls>
</>
);
};
const renderProduct = () => {
const {
contentAlign,
dimRatio,
focalPoint,
hasParallax,
isRepeated,
imageFit,
minHeight,
overlayColor,
overlayGradient,
showDesc,
showPrice,
style,
} = attributes;
const classes = classnames(
'wc-block-featured-product',
dimRatioToClass( dimRatio ),
{
'is-selected': isSelected && attributes.productId !== 'preview',
'is-loading': ! product && isLoading,
'is-not-found': ! product && ! isLoading,
'has-background-dim': dimRatio !== 0,
'is-repeated': isRepeated,
},
contentAlign !== 'center' && `has-${ contentAlign }-content`
);
const containerStyle = {
borderRadius: style?.border?.radius,
};
const backgroundImageStyle = {
objectPosition: calculateImagePosition( focalPoint ),
objectFit: imageFit,
};
const isImgElement = ! isRepeated && ! hasParallax;
const wrapperStyle = {
...getSpacingClassesAndStyles( attributes ).style,
minHeight,
};
const backgroundDivStyle = {
...( ! isImgElement
? {
...backgroundImageStyles( backgroundImageSrc ),
backgroundPosition: calculateImagePosition(
focalPoint
),
}
: undefined ),
...( ! isRepeated && {
backgroundRepeat: 'no-repeat',
backgroundSize: imageFit === 'cover' ? imageFit : 'auto',
} ),
};
const overlayStyle = {
background: overlayGradient,
backgroundColor: overlayColor,
};
return (
<>
<ConstrainedResizable
enable={ { bottom: true } }
onResize={ onResize }
showHandle={ isSelected }
style={ { minHeight } }
/>
<div className={ classes } style={ containerStyle }>
<div
className={ classnames(
'wc-block-featured-product__wrapper'
) }
style={ wrapperStyle }
>
<div
className="wc-block-featured-product__overlay"
style={ overlayStyle }
/>
{ isImgElement && (
<img
alt={ product.short_description }
className="wc-block-featured-product__background-image"
src={ backgroundImageSrc }
style={ backgroundImageStyle }
onLoad={ ( e ) => {
setBackgroundImageSize( {
height: e.target?.naturalHeight,
width: e.target?.naturalWidth,
} );
} }
/>
) }
{ ! isImgElement && (
<div
className={ classnames(
'wc-block-featured-product__background-image',
{
'has-parallax': hasParallax,
}
) }
style={ backgroundDivStyle }
/>
) }
<h2
className="wc-block-featured-product__title"
dangerouslySetInnerHTML={ {
__html: product.name,
} }
/>
{ ! isEmpty( product.variation ) && (
<h3
className="wc-block-featured-product__variation"
dangerouslySetInnerHTML={ {
__html: product.variation,
} }
/>
) }
{ showDesc && (
<div
className="wc-block-featured-product__description"
dangerouslySetInnerHTML={ {
__html: product.short_description,
} }
/>
) }
{ showPrice && (
<div
className="wc-block-featured-product__price"
dangerouslySetInnerHTML={ {
__html: product.price_html,
} }
/>
) }
<div className="wc-block-featured-product__link">
{ renderButton() }
</div>
</div>
</div>
</>
);
};
const renderButton = () => {
const buttonClasses = classnames(
'wp-block-button__link',
'is-style-fill'
);
const buttonStyle = {
backgroundColor: 'vivid-green-cyan',
borderRadius: '5px',
};
const wrapperStyle = {
width: '100%',
};
return attributes.productId === 'preview' ? (
<div className="wp-block-button aligncenter" style={ wrapperStyle }>
<RichText.Content
tagName="a"
className={ buttonClasses }
href={ product.permalink }
title={ attributes.linkText }
style={ buttonStyle }
value={ attributes.linkText }
target={ product.permalink }
/>
</div>
) : (
<InnerBlocks
template={ [
[
'core/buttons',
{
layout: { type: 'flex', justifyContent: 'center' },
},
[
[
'core/button',
{
text: __(
'Shop now',
'woo-gutenberg-products-block'
),
url: product.permalink,
},
],
],
],
] }
templateLock="all"
/>
);
};
const renderNoProduct = () => (
<Placeholder
className="wc-block-featured-product"
icon={ <Icon icon={ starEmpty } /> }
label={ __( 'Featured Product', 'woo-gutenberg-products-block' ) }
>
{ isLoading ? (
<Spinner />
) : (
__( 'No product is selected.', 'woo-gutenberg-products-block' )
) }
</Placeholder>
);
const { editMode } = attributes;
if ( error ) {
return renderApiError();
}
if ( editMode ) {
return renderEditMode();
}
if ( isEditingImage ) {
return (
<>
<ImageEditingProvider
id={ backgroundImageId }
url={ backgroundImageSrc }
naturalHeight={
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
}
naturalWidth={
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
}
onSaveImage={ ( { id, url } ) => {
setAttributes( { mediaId: id, mediaSrc: url } );
} }
isEditing={ isEditingImage }
onFinishEditing={ () => setIsEditingImage( false ) }
>
<ImageEditor
url={ backgroundImageSrc }
height={
backgroundImageSize.height ||
DEFAULT_EDITOR_SIZE.height
}
width={
backgroundImageSize.width ||
DEFAULT_EDITOR_SIZE.width
}
/>
</ImageEditingProvider>
</>
);
}
return (
<>
{ getBlockControls() }
{ getInspectorControls() }
{ product ? renderProduct() : renderNoProduct() }
</>
);
};
FeaturedProduct.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* Whether this block is currently active.
*/
isSelected: PropTypes.bool.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withProduct
error: PropTypes.object,
getProduct: PropTypes.func,
isLoading: PropTypes.bool,
product: PropTypes.shape( {
name: PropTypes.node,
variation: PropTypes.node,
description: PropTypes.node,
price_html: PropTypes.node,
permalink: PropTypes.string,
} ),
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
triggerUrlUpdate: PropTypes.func,
};
export default compose( [
withProduct,
withSpokenMessages,
withSelect( ( select, { clientId }, { dispatch } ) => {
const Block = select( 'core/block-editor' ).getBlock( clientId );
const buttonBlockId = Block?.innerBlocks[ 0 ]?.clientId || '';
const currentButtonAttributes =
Block?.innerBlocks[ 0 ]?.attributes || {};
const updateBlockAttributes = ( attributes ) => {
if ( buttonBlockId ) {
dispatch( 'core/block-editor' ).updateBlockAttributes(
buttonBlockId,
attributes
);
}
};
return { updateBlockAttributes, currentButtonAttributes };
} ),
createHigherOrderComponent( ( ProductComponent ) => {
class WrappedComponent extends Component {
state = {
doUrlUpdate: false,
};
componentDidUpdate() {
const {
attributes,
updateBlockAttributes,
currentButtonAttributes,
product,
} = this.props;
if (
this.state.doUrlUpdate &&
! attributes.editMode &&
product?.permalink &&
currentButtonAttributes?.url &&
product.permalink !== currentButtonAttributes.url
) {
updateBlockAttributes( {
...currentButtonAttributes,
url: product.permalink,
} );
this.setState( { doUrlUpdate: false } );
}
}
triggerUrlUpdate = () => {
this.setState( { doUrlUpdate: true } );
};
render() {
return (
<ProductComponent
triggerUrlUpdate={ this.triggerUrlUpdate }
{ ...this.props }
/>
);
}
}
return WrappedComponent;
}, 'withUpdateButtonAttributes' ),
] )( FeaturedProduct );

View File

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

View File

@ -1,14 +0,0 @@
.wc-block-featured-product {
background-color: inherit;
.components-resizable-box__handle {
z-index: 10;
}
}
.wc-block-featured-product__message {
margin-bottom: 16px;
}
.wc-block-featured-product__selection {
width: 100%;
}

View File

@ -1,22 +0,0 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { previewProducts } from '@woocommerce/resource-previews';
export const example = {
attributes: {
alt: '',
contentAlign: 'center',
dimRatio: 50,
editMode: false,
hasParallax: false,
isRepeated: false,
height: getSetting( 'default_height', 500 ),
mediaSrc: '',
overlayColor: '#000000',
showDesc: true,
productId: 'preview',
previewProduct: previewProducts[ 0 ],
},
};

View File

@ -1,89 +0,0 @@
/**
* External dependencies
*/
import { InnerBlocks } from '@wordpress/block-editor';
import { registerBlockType } from '@wordpress/blocks';
import { getSetting } from '@woocommerce/settings';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { Icon, starEmpty } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.scss';
import { example } from './example';
import { Edit } from './edit';
import metadata from './block.json';
/**
* Register and run the "Featured Product" block.
*/
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starEmpty }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
/**
* A minimum height for the block.
*
* Note: if padding is increased, this way the inner content will never
* overflow, but instead will resize the container.
*
* It was decided to change this to make this block more in line with
* the Cover block.
*/
minHeight: {
type: 'number',
default: getSetting( 'default_height', 500 ),
},
},
supports: {
...metadata.supports,
color: {
background: true,
text: true,
...( isFeaturePluginBuild() && {
__experimentalDuotone:
'.wc-block-featured-product__background-image',
} ),
},
spacing: {
padding: true,
...( isFeaturePluginBuild() && {
__experimentalDefaultControls: {
padding: true,
},
__experimentalSkipSerialization: true,
} ),
},
...( isFeaturePluginBuild() && {
__experimentalBorder: {
color: true,
radius: true,
width: true,
__experimentalSkipSerialization: true,
},
} ),
},
example,
/**
* Renders and manages the block.
*
* @param {Object} props Props to pass to block.
*/
edit: Edit,
/**
* Block content is rendered in PHP, not via save function.
*/
save: () => {
return <InnerBlocks.Content />;
},
} );

View File

@ -1,24 +0,0 @@
export function calculateImagePosition( coords ) {
if ( ! coords ) return {};
const x = Math.round( coords.x * 100 );
const y = Math.round( coords.y * 100 );
return `${ x }% ${ y }%`;
}
/**
* Convert the selected ratio to the correct background class.
*
* @param {number} ratio Selected opacity from 0 to 100.
* @return {string?} The class name, if applicable (not used for ratio 0 or 50).
*/
export function dimRatioToClass( ratio ) {
return ratio === 0 || ratio === 50
? null
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
}
export function backgroundImageStyles( url ) {
return { backgroundImage: `url(${ url })` };
}

View File

@ -264,11 +264,13 @@ const getMainConfig = ( options = {} ) => {
to: './checkout/block.json',
},
{
from: './assets/js/blocks/featured-category/block.json',
from:
'./assets/js/blocks/featured-items/featured-category/block.json',
to: './featured-category/block.json',
},
{
from: './assets/js/blocks/featured-product/block.json',
from:
'./assets/js/blocks/featured-items/featured-product/block.json',
to: './featured-product/block.json',
},
{

View File

@ -20,7 +20,9 @@ const blocks = {
'product-on-sale': {},
'product-top-rated': {},
'products-by-attribute': {},
'featured-product': {},
'featured-product': {
customDir: 'featured-items/featured-product',
},
'all-reviews': {
customDir: 'reviews/all-reviews',
},
@ -32,7 +34,9 @@ const blocks = {
},
'product-search': {},
'product-tag': {},
'featured-category': {},
'featured-category': {
customDir: 'featured-items/featured-category',
},
'all-products': {
customDir: 'products/all-products',
},

View File

@ -179,7 +179,7 @@ class FeaturedCategory extends AbstractDynamicBlock {
$overlay_styles = 'background-color: #000000';
}
return sprintf( '<div class="wc-block-featured-category__overlay" style="%s"></div>', esc_attr( $overlay_styles ) );
return sprintf( '<div class="background-dim__overlay" style="%s"></div>', esc_attr( $overlay_styles ) );
}
/**

View File

@ -230,7 +230,7 @@ class FeaturedProduct extends AbstractDynamicBlock {
$overlay_styles = 'background-color: #000000';
}
return sprintf( '<div class="wc-block-featured-product__overlay" style="%s"></div>', esc_attr( $overlay_styles ) );
return sprintf( '<div class="background-dim__overlay" style="%s"></div>', esc_attr( $overlay_styles ) );
}
/**