/* 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 ( { 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 = () => ( ); useEffect( () => { setIsEditingImage( false ); }, [ isSelected ] ); const renderEditMode = () => { const onDone = () => { setAttributes( { editMode: false } ); debouncedSpeak( __( 'Showing Featured Product block preview.', 'woo-gutenberg-products-block' ) ); }; return ( <> { getBlockControls() } } 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' ) }
{ const id = value[ 0 ] ? value[ 0 ].id : 0; setAttributes( { productId: id, mediaId: 0, mediaSrc: '', } ); triggerUrlUpdate(); } } />
); }; const getBlockControls = () => { const { contentAlign, editMode } = attributes; return ( { setAttributes( { contentAlign: nextAlign } ); } } /> { backgroundImageSrc && ! isEditingImage && ( setIsEditingImage( true ) } icon={ crop } label={ __( 'Edit product image', 'woo-gutenberg-products-block' ) } /> ) } { setAttributes( { mediaId: media.id, mediaSrc: media.url, } ); } } allowedTypes={ [ 'image' ] } /> { backgroundImageId && mediaSrc ? ( setAttributes( { mediaId: 0, mediaSrc: '' } ) } > { __( 'Reset', 'woo-gutenberg-products-block' ) } ) : null } setAttributes( { editMode: ! editMode } ), isActive: editMode, }, ] } /> ); }; 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 ( <> setAttributes( { showDesc: ! attributes.showDesc, } ) } /> setAttributes( { showPrice: ! attributes.showPrice, } ) } /> { !! url && ( <> { focalPointPickerExists && ( { setAttributes( { hasParallax: ! hasParallax, } ); } } /> { setAttributes( { isRepeated: ! isRepeated, } ); } } /> { ! isRepeated && (

{ __( 'Choose “Cover” if you want the image to scale automatically to always fit its container.', 'woo-gutenberg-products-block' ) }

{ __( 'Note: by choosing “Cover” you will lose the ability to freely move the focal point precisely.', 'woo-gutenberg-products-block' ) }

} label={ __( 'Image fit', 'woo-gutenberg-products-block' ) } value={ attributes.imageFit } onChange={ ( value ) => setAttributes( { imageFit: value, } ) } >
) } setAttributes( { focalPoint: value, } ) } /> { isImgElement && ( { setAttributes( { alt } ); } } help={ <> { __( 'Describe the purpose of the image', 'woo-gutenberg-products-block' ) } { __( 'Leaving it empty will use the product name.', 'woo-gutenberg-products-block' ) } } /> ) }
) } setAttributes( { overlayColor } ), onGradientChange: ( overlayGradient ) => { setGradient( overlayGradient ); setAttributes( { overlayGradient, } ); }, label: __( 'Color', 'woo-gutenberg-products-block' ), }, ] } > setAttributes( { dimRatio } ) } min={ 0 } max={ 100 } step={ 10 } required /> ) }
); }; 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 ( <>
{ isImgElement && ( { { setBackgroundImageSize( { height: e.target?.naturalHeight, width: e.target?.naturalWidth, } ); } } /> ) } { ! isImgElement && (
) }

{ ! isEmpty( product.variation ) && (

) } { showDesc && (
) } { showPrice && (
) }
{ renderButton() }
); }; 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' ? (
) : ( ); }; const renderNoProduct = () => ( } label={ __( 'Featured Product', 'woo-gutenberg-products-block' ) } > { isLoading ? ( ) : ( __( 'No product is selected.', 'woo-gutenberg-products-block' ) ) } ); const { editMode } = attributes; if ( error ) { return renderApiError(); } if ( editMode ) { return renderEditMode(); } if ( isEditingImage ) { return ( <> { setAttributes( { mediaId: id, mediaSrc: url } ); } } isEditing={ isEditingImage } onFinishEditing={ () => setIsEditingImage( false ) } > ); } 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 ( ); } } return WrappedComponent; }, 'withUpdateButtonAttributes' ), ] )( FeaturedProduct );