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:
parent
b1f4e35c00
commit
7fcc561db1
|
@ -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.
|
||||
|
|
|
@ -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 );
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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%;
|
||||
}
|
|
@ -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 />;
|
||||
},
|
||||
} );
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 }%`,
|
||||
};
|
||||
}
|
|
@ -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 } />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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 }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 ),
|
||||
] )( () => <></> );
|
|
@ -0,0 +1,6 @@
|
|||
@import "../style";
|
||||
|
||||
.wp-block-woocommerce-featured-category {
|
||||
@extend %with-media-controls;
|
||||
@extend %with-resizable-box;
|
||||
}
|
|
@ -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 ],
|
||||
},
|
|
@ -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"
|
||||
/>
|
||||
),
|
||||
},
|
||||
} );
|
|
@ -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();
|
||||
}
|
|
@ -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 };
|
|
@ -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 ),
|
||||
] )( () => <></> );
|
|
@ -0,0 +1,10 @@
|
|||
@import "../style";
|
||||
|
||||
.wp-block-woocommerce-featured-product {
|
||||
@extend %with-media-controls;
|
||||
@extend %with-resizable-box;
|
||||
|
||||
&__message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { previewProducts } from '@woocommerce/resource-previews';
|
||||
|
||||
export const example = {
|
||||
attributes: {
|
||||
productId: 'preview',
|
||||
previewProduct: previewProducts[ 0 ],
|
||||
},
|
||||
};
|
|
@ -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"
|
||||
/>
|
||||
),
|
||||
},
|
||||
} );
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 } />;
|
||||
};
|
|
@ -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 } />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
} );
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 ) }`;
|
||||
}
|
|
@ -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 } />;
|
||||
};
|
|
@ -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 } />;
|
||||
};
|
|
@ -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 ] }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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() }
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 );
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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%;
|
||||
}
|
|
@ -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 ],
|
||||
},
|
||||
};
|
|
@ -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 />;
|
||||
},
|
||||
} );
|
|
@ -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 })` };
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue