rebase from live enviornment
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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 { WP_REST_API_Category } from 'wp-types';
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
import type { ComponentType, Dispatch, SetStateAction } from 'react';
|
||||
import type { BlockAlignment } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useBackgroundImage } from './use-background-image';
|
||||
import { EditorBlock, GenericBlockUIConfig } from './types';
|
||||
|
||||
type Media = { id: number; url: string };
|
||||
|
||||
interface WithBlockControlsRequiredProps< T > {
|
||||
attributes: BlockControlRequiredAttributes &
|
||||
EditorBlock< T >[ 'attributes' ];
|
||||
setAttributes: ( attrs: Partial< BlockControlRequiredAttributes > ) => void;
|
||||
useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
|
||||
}
|
||||
|
||||
interface WithBlockControlsCategoryProps< T >
|
||||
extends WithBlockControlsRequiredProps< T > {
|
||||
category: WP_REST_API_Category;
|
||||
product: never;
|
||||
}
|
||||
|
||||
interface WithBlockControlsProductProps< T >
|
||||
extends WithBlockControlsRequiredProps< T > {
|
||||
category: never;
|
||||
product: ProductResponseItem;
|
||||
}
|
||||
|
||||
type WithBlockControlsProps< T extends EditorBlock< T > > =
|
||||
| ( T & WithBlockControlsCategoryProps< T > )
|
||||
| ( T & WithBlockControlsProductProps< T > );
|
||||
|
||||
type BlockControlRequiredAttributes = {
|
||||
contentAlign: BlockAlignment;
|
||||
editMode: boolean;
|
||||
mediaId: number;
|
||||
mediaSrc: string;
|
||||
};
|
||||
|
||||
interface BlockControlsProps {
|
||||
backgroundImageId: number;
|
||||
backgroundImageSrc: string;
|
||||
contentAlign: BlockAlignment;
|
||||
cropLabel: string;
|
||||
editLabel: string;
|
||||
editMode: boolean;
|
||||
isEditingImage: boolean;
|
||||
mediaSrc: string;
|
||||
setAttributes: ( attrs: Partial< BlockControlRequiredAttributes > ) => void;
|
||||
setIsEditingImage: ( value: boolean ) => void;
|
||||
}
|
||||
|
||||
interface BlockControlsConfiguration extends GenericBlockUIConfig {
|
||||
cropLabel: string;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
export const BlockControls = ( {
|
||||
backgroundImageId,
|
||||
backgroundImageSrc,
|
||||
contentAlign,
|
||||
cropLabel,
|
||||
editLabel,
|
||||
editMode,
|
||||
isEditingImage,
|
||||
mediaSrc,
|
||||
setAttributes,
|
||||
setIsEditingImage,
|
||||
}: BlockControlsProps ) => {
|
||||
return (
|
||||
<BlockControlsWrapper>
|
||||
<AlignmentToolbar
|
||||
value={ contentAlign }
|
||||
onChange={ ( nextAlign: BlockAlignment ) => {
|
||||
setAttributes( { contentAlign: nextAlign } );
|
||||
} }
|
||||
/>
|
||||
<ToolbarGroup>
|
||||
{ backgroundImageSrc && ! isEditingImage && (
|
||||
<ToolbarButton
|
||||
onClick={ () => setIsEditingImage( true ) }
|
||||
icon={ crop }
|
||||
label={ cropLabel }
|
||||
/>
|
||||
) }
|
||||
<MediaReplaceFlow
|
||||
mediaId={ backgroundImageId }
|
||||
mediaURL={ mediaSrc }
|
||||
accept="image/*"
|
||||
onSelect={ ( media: Media ) => {
|
||||
setAttributes( {
|
||||
mediaId: media.id,
|
||||
mediaSrc: media.url,
|
||||
} );
|
||||
} }
|
||||
allowedTypes={ [ 'image' ] }
|
||||
/>
|
||||
{ backgroundImageId && mediaSrc ? (
|
||||
<ToolbarButton
|
||||
onClick={ () =>
|
||||
setAttributes( { mediaId: 0, mediaSrc: '' } )
|
||||
}
|
||||
>
|
||||
{ __( 'Reset', 'woo-gutenberg-products-block' ) }
|
||||
</ToolbarButton>
|
||||
) : null }
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup
|
||||
controls={ [
|
||||
{
|
||||
icon: 'edit',
|
||||
title: editLabel,
|
||||
onClick: () =>
|
||||
setAttributes( { editMode: ! editMode } ),
|
||||
isActive: editMode,
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</BlockControlsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const withBlockControls =
|
||||
( { cropLabel, editLabel }: BlockControlsConfiguration ) =>
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: WithBlockControlsProps< T > ) => {
|
||||
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,67 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { RichText, InnerBlocks } from '@wordpress/block-editor';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
interface CallToActionProps {
|
||||
itemId: number | 'preview';
|
||||
linkText: string;
|
||||
permalink: string;
|
||||
}
|
||||
|
||||
export const CallToAction = ( {
|
||||
itemId,
|
||||
linkText,
|
||||
permalink,
|
||||
}: CallToActionProps ) => {
|
||||
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,
|
||||
} as const;
|
||||
|
||||
export const BLOCK_NAMES = {
|
||||
featuredCategory: 'woocommerce/featured-category',
|
||||
featuredProduct: 'woocommerce/featured-product',
|
||||
} as const;
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { ResizableBox } from '@wordpress/components';
|
||||
import { useThrottledCallback } from 'use-debounce';
|
||||
|
||||
type ResizeCallback = Exclude< ResizableBox.Props[ 'onResize' ], undefined >;
|
||||
|
||||
export const ConstrainedResizable = ( {
|
||||
className = '',
|
||||
onResize,
|
||||
...props
|
||||
}: ResizableBox.Props ): JSX.Element => {
|
||||
const [ isResizing, setIsResizing ] = useState( false );
|
||||
|
||||
const classNames = classnames( className, {
|
||||
'is-resizing': isResizing,
|
||||
} );
|
||||
const throttledResize = useThrottledCallback< ResizeCallback >(
|
||||
( event, direction, elt, _delta ) => {
|
||||
if ( ! isResizing ) setIsResizing( true );
|
||||
onResize?.( event, direction, elt, _delta );
|
||||
},
|
||||
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 { useBlockProps } from '@wordpress/block-editor';
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
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,108 @@
|
||||
{
|
||||
"name": "woocommerce/featured-category",
|
||||
"version": "1.0.0",
|
||||
"title": "Featured Category",
|
||||
"category": "woocommerce",
|
||||
"keywords": [
|
||||
"WooCommerce"
|
||||
],
|
||||
"description": "Visually highlight a product category and encourage prompt action.",
|
||||
"supports": {
|
||||
"align": [
|
||||
"wide",
|
||||
"full"
|
||||
],
|
||||
"html": false,
|
||||
"color": {
|
||||
"background": true,
|
||||
"text": true
|
||||
},
|
||||
"spacing": {
|
||||
"padding": true,
|
||||
"__experimentalDefaultControls": {
|
||||
"padding": true
|
||||
},
|
||||
"__experimentalSkipSerialization": true
|
||||
},
|
||||
"__experimentalBorder": {
|
||||
"color": true,
|
||||
"radius": true,
|
||||
"width": true,
|
||||
"__experimentalSkipSerialization": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"contentAlign": {
|
||||
"type": "string",
|
||||
"default": "center"
|
||||
},
|
||||
"dimRatio": {
|
||||
"type": "number",
|
||||
"default": 50
|
||||
},
|
||||
"editMode": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"focalPoint": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"x": 0.5,
|
||||
"y": 0.5
|
||||
}
|
||||
},
|
||||
"imageFit": {
|
||||
"type": "string",
|
||||
"default": "none"
|
||||
},
|
||||
"hasParallax": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"isRepeated": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"mediaId": {
|
||||
"type": "number",
|
||||
"default": 0
|
||||
},
|
||||
"mediaSrc": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"minHeight": {
|
||||
"type": "number",
|
||||
"default": 500
|
||||
},
|
||||
"linkText": {
|
||||
"default": "Shop now",
|
||||
"type": "string"
|
||||
},
|
||||
"categoryId": {
|
||||
"type": "number"
|
||||
},
|
||||
"overlayColor": {
|
||||
"type": "string",
|
||||
"default": "#000000"
|
||||
},
|
||||
"overlayGradient": {
|
||||
"type": "string"
|
||||
},
|
||||
"previewCategory": {
|
||||
"type": "object",
|
||||
"default": null
|
||||
},
|
||||
"showDesc": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"textdomain": "woocommerce",
|
||||
"apiVersion": 2,
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { withCategory } from '@woocommerce/block-hocs';
|
||||
import { withSpokenMessages } from '@wordpress/components';
|
||||
import { compose } from '@wordpress/compose';
|
||||
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';
|
||||
import { withUpdateButtonAttributes } from '../with-update-button-attributes';
|
||||
|
||||
const GENERIC_CONFIG = {
|
||||
icon: folderStarred,
|
||||
label: __( 'Featured Category', 'woo-gutenberg-products-block' ),
|
||||
};
|
||||
|
||||
const BLOCK_CONTROL_CONFIG = {
|
||||
...GENERIC_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'
|
||||
),
|
||||
noSelectionButtonLabel: __(
|
||||
'Select a category',
|
||||
'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,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { previewCategories } from '@woocommerce/resource-previews';
|
||||
import type { Block } from '@wordpress/blocks';
|
||||
|
||||
type ExampleBlock = Block[ 'example' ] & {
|
||||
attributes: {
|
||||
categoryId: 'preview' | number;
|
||||
previewCategory: typeof previewCategories[ number ];
|
||||
editMode: false;
|
||||
};
|
||||
};
|
||||
|
||||
export const example: ExampleBlock = {
|
||||
attributes: {
|
||||
categoryId: 'preview',
|
||||
previewCategory: previewCategories[ 0 ],
|
||||
editMode: false,
|
||||
},
|
||||
} as const;
|
||||
@@ -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,29 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { isImageObject } from '../types';
|
||||
|
||||
/**
|
||||
* Get the src from a category object, unless null (no image).
|
||||
*/
|
||||
export function getCategoryImageSrc( category: WP_REST_API_Category ) {
|
||||
if ( category && isImageObject( category.image ) ) {
|
||||
return category.image.src;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachment ID from a category object, unless null (no image).
|
||||
*/
|
||||
export function getCategoryImageId( category: WP_REST_API_Category ) {
|
||||
if ( category && isImageObject( category.image ) ) {
|
||||
return category.image.id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "woocommerce/featured-product",
|
||||
"version": "1.0.0",
|
||||
"title": "Featured Product",
|
||||
"description": "Highlight a product or variation.",
|
||||
"category": "woocommerce",
|
||||
"keywords": [ "WooCommerce" ],
|
||||
"supports": {
|
||||
"align": [ "wide", "full" ],
|
||||
"html": false,
|
||||
"color": {
|
||||
"background": true,
|
||||
"text": true
|
||||
},
|
||||
"spacing": {
|
||||
"padding": true,
|
||||
"__experimentalDefaultControls": {
|
||||
"padding": true
|
||||
},
|
||||
"__experimentalSkipSerialization": true
|
||||
},
|
||||
"__experimentalBorder": {
|
||||
"color": true,
|
||||
"radius": true,
|
||||
"width": true,
|
||||
"__experimentalSkipSerialization": true
|
||||
},
|
||||
"multiple": true
|
||||
},
|
||||
"attributes": {
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"contentAlign": {
|
||||
"type": "string",
|
||||
"default": "center"
|
||||
},
|
||||
"dimRatio": {
|
||||
"type": "number",
|
||||
"default": 50
|
||||
},
|
||||
"editMode": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"focalPoint": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"x": 0.5,
|
||||
"y": 0.5
|
||||
}
|
||||
},
|
||||
"imageFit": {
|
||||
"type": "string",
|
||||
"default": "none"
|
||||
},
|
||||
"hasParallax": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"isRepeated": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"mediaId": {
|
||||
"type": "number",
|
||||
"default": 0
|
||||
},
|
||||
"mediaSrc": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"minHeight": {
|
||||
"type": "number",
|
||||
"default": 500
|
||||
},
|
||||
"linkText": {
|
||||
"type": "string",
|
||||
"default": "Shop now"
|
||||
},
|
||||
"overlayColor": {
|
||||
"type": "string",
|
||||
"default": "#000000"
|
||||
},
|
||||
"overlayGradient": {
|
||||
"type": "string"
|
||||
},
|
||||
"productId": {
|
||||
"type": "number"
|
||||
},
|
||||
"previewProduct": {
|
||||
"type": "object",
|
||||
"default": null
|
||||
},
|
||||
"showDesc": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"showPrice": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"textdomain": "woocommerce",
|
||||
"apiVersion": 2,
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { withProduct } from '@woocommerce/block-hocs';
|
||||
import { withSpokenMessages } from '@wordpress/components';
|
||||
import { compose } from '@wordpress/compose';
|
||||
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';
|
||||
import { withUpdateButtonAttributes } from '../with-update-button-attributes';
|
||||
|
||||
const GENERIC_CONFIG = {
|
||||
icon: starEmpty,
|
||||
label: __( 'Featured Product', 'woo-gutenberg-products-block' ),
|
||||
};
|
||||
|
||||
const BLOCK_CONTROL_CONFIG = {
|
||||
...GENERIC_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'
|
||||
),
|
||||
noSelectionButtonLabel: __(
|
||||
'Select a product',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
};
|
||||
|
||||
const EDIT_MODE_CONFIG = {
|
||||
...GENERIC_CONFIG,
|
||||
description: __(
|
||||
'Highlight a product or variation.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
editLabel: __(
|
||||
'Showing Featured Product block preview.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
};
|
||||
|
||||
export default compose( [
|
||||
withProduct,
|
||||
withSpokenMessages,
|
||||
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,21 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { previewProducts } from '@woocommerce/resource-previews';
|
||||
import type { Block } from '@wordpress/blocks';
|
||||
|
||||
type ExampleBlock = Block[ 'example' ] & {
|
||||
attributes: {
|
||||
productId: 'preview' | number;
|
||||
previewProduct: typeof previewProducts[ number ];
|
||||
editMode: false;
|
||||
};
|
||||
};
|
||||
|
||||
export const example: ExampleBlock = {
|
||||
attributes: {
|
||||
productId: 'preview',
|
||||
previewProduct: previewProducts[ 0 ],
|
||||
editMode: false,
|
||||
},
|
||||
} as const;
|
||||
@@ -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,197 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
import {
|
||||
__experimentalImageEditingProvider as ImageEditingProvider,
|
||||
__experimentalImageEditor as GutenbergImageEditor,
|
||||
} from '@wordpress/block-editor';
|
||||
import type { ComponentType, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { BLOCK_NAMES, DEFAULT_EDITOR_SIZE } from './constants';
|
||||
import { EditorBlock } from './types';
|
||||
import { useBackgroundImage } from './use-background-image';
|
||||
|
||||
type MediaAttributes = { align: string; mediaId: number; mediaSrc: string };
|
||||
type MediaSize = { height: number; width: number };
|
||||
|
||||
interface WithImageEditorRequiredProps< T > {
|
||||
attributes: MediaAttributes & EditorBlock< T >[ 'attributes' ];
|
||||
backgroundImageSize: MediaSize;
|
||||
setAttributes: ( attrs: Partial< MediaAttributes > ) => void;
|
||||
useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
|
||||
}
|
||||
|
||||
interface WithImageEditorCategoryProps< T >
|
||||
extends WithImageEditorRequiredProps< T > {
|
||||
category: WP_REST_API_Category;
|
||||
product: never;
|
||||
}
|
||||
|
||||
interface WithImageEditorProductProps< T >
|
||||
extends WithImageEditorRequiredProps< T > {
|
||||
category: never;
|
||||
product: ProductResponseItem;
|
||||
}
|
||||
|
||||
type WithImageEditorProps< T extends EditorBlock< T > > =
|
||||
| ( T & WithImageEditorCategoryProps< T > )
|
||||
| ( T & WithImageEditorProductProps< T > );
|
||||
|
||||
interface ImageEditorProps {
|
||||
align: string;
|
||||
backgroundImageId: number;
|
||||
backgroundImageSize: MediaSize;
|
||||
backgroundImageSrc: string;
|
||||
containerRef: React.RefObject< HTMLDivElement >;
|
||||
isEditingImage: boolean;
|
||||
setAttributes: ( attrs: MediaAttributes ) => void;
|
||||
setIsEditingImage: ( value: boolean ) => void;
|
||||
}
|
||||
|
||||
// Adapted from:
|
||||
// https://github.com/WordPress/gutenberg/blob/v15.6.1/packages/block-library/src/image/use-client-width.js
|
||||
function useClientWidth(
|
||||
ref: React.RefObject< HTMLDivElement >,
|
||||
dependencies: string[]
|
||||
) {
|
||||
const [ clientWidth, setClientWidth ]: [
|
||||
number | undefined,
|
||||
Dispatch< SetStateAction< number | undefined > >
|
||||
] = useState();
|
||||
|
||||
const calculateClientWidth = useCallback( () => {
|
||||
setClientWidth( ref.current?.clientWidth );
|
||||
}, [ ref ] );
|
||||
|
||||
useEffect( calculateClientWidth, [
|
||||
calculateClientWidth,
|
||||
...dependencies,
|
||||
] );
|
||||
useEffect( () => {
|
||||
if ( ! ref.current ) {
|
||||
return;
|
||||
}
|
||||
const { defaultView } = ref.current.ownerDocument;
|
||||
|
||||
if ( ! defaultView ) {
|
||||
return;
|
||||
}
|
||||
defaultView.addEventListener( 'resize', calculateClientWidth );
|
||||
|
||||
return () => {
|
||||
defaultView.removeEventListener( 'resize', calculateClientWidth );
|
||||
};
|
||||
}, [ ref, calculateClientWidth ] );
|
||||
|
||||
return clientWidth;
|
||||
}
|
||||
|
||||
export const ImageEditor = ( {
|
||||
align,
|
||||
backgroundImageId,
|
||||
backgroundImageSize,
|
||||
backgroundImageSrc,
|
||||
containerRef,
|
||||
isEditingImage,
|
||||
setAttributes,
|
||||
setIsEditingImage,
|
||||
}: ImageEditorProps ) => {
|
||||
const clientWidth = useClientWidth( containerRef, [ align ] );
|
||||
|
||||
// Fallback for WP 6.1 or lower. In WP 6.2. ImageEditingProvider was merged
|
||||
// with ImageEditor, see: https://github.com/WordPress/gutenberg/pull/47171
|
||||
if ( typeof ImageEditingProvider === 'function' ) {
|
||||
return (
|
||||
<ImageEditingProvider
|
||||
id={ backgroundImageId }
|
||||
url={ backgroundImageSrc }
|
||||
naturalHeight={
|
||||
backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
|
||||
}
|
||||
naturalWidth={
|
||||
backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
|
||||
}
|
||||
onSaveImage={ ( { id, url }: { id: number; url: string } ) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GutenbergImageEditor
|
||||
id={ backgroundImageId }
|
||||
url={ backgroundImageSrc }
|
||||
height={ backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height }
|
||||
width={ backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width }
|
||||
naturalHeight={ backgroundImageSize.height }
|
||||
naturalWidth={ backgroundImageSize.width }
|
||||
onSaveImage={ ( { id, url }: { id: number; url: string } ) => {
|
||||
setAttributes( { mediaId: id, mediaSrc: url } );
|
||||
} }
|
||||
onFinishEditing={ () => setIsEditingImage( false ) }
|
||||
clientWidth={ clientWidth }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const withImageEditor =
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: WithImageEditorProps< T > ) => {
|
||||
const [ isEditingImage, setIsEditingImage ] = props.useEditingImage;
|
||||
|
||||
const ref = useRef< HTMLDivElement >( null );
|
||||
|
||||
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 (
|
||||
<div ref={ ref }>
|
||||
<ImageEditor
|
||||
align={ attributes.align }
|
||||
backgroundImageId={ backgroundImageId }
|
||||
backgroundImageSize={ backgroundImageSize }
|
||||
backgroundImageSrc={ backgroundImageSrc }
|
||||
containerRef={ ref }
|
||||
isEditingImage={ isEditingImage }
|
||||
setAttributes={ setAttributes }
|
||||
setIsEditingImage={ setIsEditingImage }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component { ...props } />;
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
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';
|
||||
import { LooselyMustHave, ProductResponseItem } from '@woocommerce/types';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useBackgroundImage } from './use-background-image';
|
||||
import { BLOCK_NAMES } from './constants';
|
||||
import { FeaturedItemRequiredAttributes } from './with-featured-item';
|
||||
import { EditorBlock, ImageFit } from './types';
|
||||
|
||||
type InspectorControlRequiredKeys =
|
||||
| 'dimRatio'
|
||||
| 'focalPoint'
|
||||
| 'hasParallax'
|
||||
| 'imageFit'
|
||||
| 'isRepeated'
|
||||
| 'overlayColor'
|
||||
| 'overlayGradient'
|
||||
| 'showDesc';
|
||||
|
||||
interface InspectorControlsRequiredAttributes
|
||||
extends LooselyMustHave<
|
||||
FeaturedItemRequiredAttributes,
|
||||
InspectorControlRequiredKeys
|
||||
> {
|
||||
alt: string;
|
||||
backgroundImageSrc: string;
|
||||
contentPanel: JSX.Element | undefined;
|
||||
}
|
||||
|
||||
interface InspectorControlsProps extends InspectorControlsRequiredAttributes {
|
||||
setAttributes: (
|
||||
attrs: Partial< InspectorControlsRequiredAttributes >
|
||||
) => void;
|
||||
// Gutenberg doesn't provide some types, so we have to hard-code them here
|
||||
setGradient: ( newGradientValue: string ) => void;
|
||||
}
|
||||
|
||||
interface WithInspectorControlsRequiredProps< T > {
|
||||
attributes: InspectorControlsRequiredAttributes &
|
||||
EditorBlock< T >[ 'attributes' ];
|
||||
setAttributes: InspectorControlsProps[ 'setAttributes' ];
|
||||
}
|
||||
|
||||
interface WithInspectorControlsCategoryProps< T >
|
||||
extends WithInspectorControlsRequiredProps< T > {
|
||||
category: WP_REST_API_Category;
|
||||
product: never;
|
||||
}
|
||||
|
||||
interface WithInspectorControlsProductProps< T >
|
||||
extends WithInspectorControlsRequiredProps< T > {
|
||||
category: never;
|
||||
product: ProductResponseItem;
|
||||
showPrice: boolean;
|
||||
}
|
||||
|
||||
type WithInspectorControlsProps< T extends EditorBlock< T > > =
|
||||
| ( T & WithInspectorControlsCategoryProps< T > )
|
||||
| ( T & WithInspectorControlsProductProps< T > );
|
||||
|
||||
export const InspectorControls = ( {
|
||||
alt,
|
||||
backgroundImageSrc,
|
||||
contentPanel,
|
||||
dimRatio,
|
||||
focalPoint,
|
||||
hasParallax,
|
||||
imageFit,
|
||||
isRepeated,
|
||||
overlayColor,
|
||||
overlayGradient,
|
||||
setAttributes,
|
||||
setGradient,
|
||||
showDesc,
|
||||
}: InspectorControlsProps ) => {
|
||||
// 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={
|
||||
<>
|
||||
<span
|
||||
style={ {
|
||||
display: 'block',
|
||||
marginBottom: '1em',
|
||||
} }
|
||||
>
|
||||
{ __(
|
||||
'Select “Cover” to have the image automatically fit its container.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</span>
|
||||
<span>
|
||||
{ __(
|
||||
'This may affect your ability to freely move the focal point of the image.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
label={ __(
|
||||
'Image fit',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
value={ imageFit }
|
||||
onChange={ ( value: ImageFit ) =>
|
||||
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: string ) => {
|
||||
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: string ) =>
|
||||
setAttributes( { overlayColor: value } ),
|
||||
onGradientChange: ( value: string ) => {
|
||||
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 as number } )
|
||||
}
|
||||
min={ 0 }
|
||||
max={ 100 }
|
||||
step={ 10 }
|
||||
required
|
||||
/>
|
||||
</PanelColorGradientSettings>
|
||||
</>
|
||||
) }
|
||||
</GutenbergInspectorControls>
|
||||
);
|
||||
};
|
||||
|
||||
export const withInspectorControls =
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: WithInspectorControlsProps< T > ) => {
|
||||
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,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
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,128 @@
|
||||
// Disabling because of `__experimental` property names.
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
/**
|
||||
* 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 type { FunctionComponent } from 'react';
|
||||
import type { BlockConfiguration } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit } from './edit';
|
||||
|
||||
type CSSDirections = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
interface ExtendedBlockSupports {
|
||||
supports: {
|
||||
color?: {
|
||||
background: string;
|
||||
gradients: boolean;
|
||||
link: boolean;
|
||||
text: string;
|
||||
};
|
||||
spacing?: {
|
||||
margin: boolean | CSSDirections[];
|
||||
padding: boolean | CSSDirections[];
|
||||
__experimentalDefaultControls?: {
|
||||
margin?: boolean;
|
||||
padding?: boolean;
|
||||
};
|
||||
__experimentalSkipSerialization?: boolean;
|
||||
};
|
||||
__experimentalBorder?: {
|
||||
color: boolean;
|
||||
radius: boolean;
|
||||
width: boolean;
|
||||
__experimentalSkipSerialization?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function register(
|
||||
Block: FunctionComponent,
|
||||
example: { attributes: Record< string, unknown > },
|
||||
metadata: BlockConfiguration & ExtendedBlockSupports,
|
||||
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( 'defaultHeight', 500 ),
|
||||
},
|
||||
},
|
||||
supports: {
|
||||
...metadata.supports,
|
||||
color: {
|
||||
background: metadata.supports?.color?.background,
|
||||
text: metadata.supports?.color?.text,
|
||||
},
|
||||
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( 'defaultHeight', 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,
|
||||
} );
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
@mixin with-content-selection {
|
||||
background-color: inherit;
|
||||
|
||||
&__selection {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
%with-media-controls {
|
||||
// Applying image edits
|
||||
.is-applying {
|
||||
.components-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -9px;
|
||||
margin-left: -9px;
|
||||
}
|
||||
|
||||
img {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%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-position: center center;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.has-left-content {
|
||||
justify-content: flex-start;
|
||||
|
||||
#{$block}__description,
|
||||
#{$block}__price,
|
||||
#{$block}__title,
|
||||
#{$block}__variation {
|
||||
margin-left: 0;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-right-content {
|
||||
justify-content: flex-end;
|
||||
|
||||
#{$block}__description,
|
||||
#{$block}__price,
|
||||
#{$block}__title,
|
||||
#{$block}__variation {
|
||||
margin-right: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-repeated {
|
||||
background-repeat: repeat;
|
||||
background-size: auto;
|
||||
}
|
||||
|
||||
&__description,
|
||||
&__price,
|
||||
&__title,
|
||||
&__variation {
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
|
||||
a,
|
||||
a:hover,
|
||||
a:focus,
|
||||
a:active {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&__description,
|
||||
&__link,
|
||||
&__price,
|
||||
&__title,
|
||||
&__variation {
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
padding: 0 48px 16px 48px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
& &__background-image {
|
||||
@include absolute-stretch();
|
||||
object-fit: none;
|
||||
|
||||
&.has-parallax {
|
||||
background-attachment: fixed;
|
||||
|
||||
// Mobile Safari does not support fixed background attachment properly.
|
||||
// See also https://stackoverflow.com/questions/24154666/background-size-cover-not-working-on-ios
|
||||
// Chrome on Android does not appear to support the attachment at all: https://issuetracker.google.com/issues/36908439
|
||||
@supports (-webkit-overflow-scrolling: touch) {
|
||||
background-attachment: scroll;
|
||||
}
|
||||
|
||||
// Remove the appearance of scrolling based on OS-level animation preferences.
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
background-attachment: scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: inherit;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& &__title {
|
||||
color: inherit;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Block, BlockEditProps } from '@wordpress/blocks';
|
||||
import { isNumber } from '@woocommerce/types';
|
||||
|
||||
export type EditorBlock< T > = Block< T > & BlockEditProps< T >;
|
||||
|
||||
export interface Coordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface GenericBlockUIConfig {
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type ImageFit = 'cover' | 'none';
|
||||
|
||||
export interface ImageObject {
|
||||
id: number;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function isImageObject( obj: unknown ): obj is ImageObject {
|
||||
if ( ! obj ) return false;
|
||||
|
||||
return (
|
||||
isNumber( ( obj as ImageObject ).id ) &&
|
||||
typeof ( obj as ImageObject ).src === 'string'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
import {
|
||||
getImageSrcFromProduct,
|
||||
getImageIdFromProduct,
|
||||
} from '@woocommerce/utils';
|
||||
import { useEffect, useState } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
getCategoryImageSrc,
|
||||
getCategoryImageId,
|
||||
} from './featured-category/utils';
|
||||
|
||||
interface BackgroundProps {
|
||||
blockName: string;
|
||||
item: ProductResponseItem | WP_REST_API_Category;
|
||||
mediaId: number | undefined;
|
||||
mediaSrc: string | undefined;
|
||||
}
|
||||
|
||||
interface BackgroundImage {
|
||||
backgroundImageId: number;
|
||||
backgroundImageSrc: string;
|
||||
}
|
||||
|
||||
export function useBackgroundImage( {
|
||||
blockName,
|
||||
item,
|
||||
mediaId,
|
||||
mediaSrc,
|
||||
}: BackgroundProps ): BackgroundImage {
|
||||
const [ backgroundImageId, setBackgroundImageId ] = useState( 0 );
|
||||
const [ backgroundImageSrc, setBackgroundImageSrc ] = useState( '' );
|
||||
|
||||
useEffect( () => {
|
||||
if ( mediaId ) {
|
||||
setBackgroundImageId( mediaId );
|
||||
} else {
|
||||
setBackgroundImageId(
|
||||
blockName === BLOCK_NAMES.featuredProduct
|
||||
? getImageIdFromProduct( item as ProductResponseItem )
|
||||
: getCategoryImageId( item as WP_REST_API_Category )
|
||||
);
|
||||
}
|
||||
}, [ blockName, item, mediaId ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( mediaSrc ) {
|
||||
setBackgroundImageSrc( mediaSrc );
|
||||
} else {
|
||||
setBackgroundImageSrc(
|
||||
blockName === BLOCK_NAMES.featuredProduct
|
||||
? getImageSrcFromProduct( item as ProductResponseItem )
|
||||
: getCategoryImageSrc( item as WP_REST_API_Category )
|
||||
);
|
||||
}
|
||||
}, [ blockName, item, mediaSrc ] );
|
||||
|
||||
return { backgroundImageId, backgroundImageSrc };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Coordinates, ImageFit } from './types';
|
||||
|
||||
/**
|
||||
* Given x and y coordinates between 0 and 1 returns a rounded percentage string.
|
||||
*
|
||||
* Useful for converting to a CSS-compatible position string.
|
||||
*/
|
||||
export function calculatePercentPositionFromCoordinates( coords: Coordinates ) {
|
||||
if ( ! coords ) return '';
|
||||
|
||||
const x = Math.round( coords.x * 100 );
|
||||
const y = Math.round( coords.y * 100 );
|
||||
|
||||
return `${ x }% ${ y }%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given x and y coordinates between 0 and 1 returns a CSS `objectPosition`.
|
||||
*/
|
||||
export function calculateBackgroundImagePosition( coords: Coordinates ) {
|
||||
if ( ! coords ) return {};
|
||||
|
||||
return {
|
||||
objectPosition: calculatePercentPositionFromCoordinates( coords ),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function getBackgroundImageStyles( {
|
||||
focalPoint,
|
||||
imageFit,
|
||||
isImgElement,
|
||||
isRepeated,
|
||||
url,
|
||||
}: {
|
||||
focalPoint: Coordinates;
|
||||
imageFit: ImageFit;
|
||||
isImgElement: boolean;
|
||||
isRepeated: boolean;
|
||||
url: string;
|
||||
} ) {
|
||||
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.
|
||||
*/
|
||||
export function getClassPrefixFromName( blockName: string ) {
|
||||
return `wc-block-${ blockName.split( '/' )[ 1 ] }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the selected ratio to the correct background class.
|
||||
*
|
||||
* @param ratio Selected opacity from 0 to 100.
|
||||
* @return The class name, if applicable (not used for ratio 0 or 50).
|
||||
*/
|
||||
export function dimRatioToClass( ratio: number ) {
|
||||
return ratio === 0 || ratio === 50
|
||||
? null
|
||||
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import ErrorPlaceholder, {
|
||||
ErrorObject,
|
||||
} from '@woocommerce/editor-components/error-placeholder';
|
||||
import type { Block } from '@wordpress/blocks';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { BLOCK_NAMES } from './constants';
|
||||
import { getClassPrefixFromName } from './utils';
|
||||
|
||||
interface APIErrorRequiredProps {
|
||||
error: ErrorObject;
|
||||
isLoading: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface APIErrorProductProps extends APIErrorRequiredProps {
|
||||
getCategory: never;
|
||||
getProduct(): void;
|
||||
}
|
||||
|
||||
interface APIErrorCategoryProps extends APIErrorRequiredProps {
|
||||
getCategory(): void;
|
||||
getProduct: never;
|
||||
}
|
||||
|
||||
type APIErrorProps< T extends Block > =
|
||||
| ( T & APIErrorProductProps )
|
||||
| ( T & APIErrorCategoryProps );
|
||||
|
||||
export const withApiError =
|
||||
< T extends Block >( Component: ComponentType< T > ) =>
|
||||
( props: APIErrorProps< T > ) => {
|
||||
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,129 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
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';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { BLOCK_NAMES } from './constants';
|
||||
import { EditorBlock, GenericBlockUIConfig } from './types';
|
||||
import { getClassPrefixFromName } from './utils';
|
||||
|
||||
interface EditModeConfiguration extends GenericBlockUIConfig {
|
||||
description: string;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
type EditModeRequiredAttributes = {
|
||||
categoryId?: number;
|
||||
editMode: boolean;
|
||||
mediaId: number;
|
||||
mediaSrc: string;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
interface EditModeRequiredProps< T > {
|
||||
attributes: EditModeRequiredAttributes & EditorBlock< T >[ 'attributes' ];
|
||||
debouncedSpeak: ( label: string ) => void;
|
||||
setAttributes: ( attrs: Partial< EditModeRequiredAttributes > ) => void;
|
||||
triggerUrlUpdate: () => void;
|
||||
}
|
||||
|
||||
type EditModeProps< T extends EditorBlock< T > > = T &
|
||||
EditModeRequiredProps< T >;
|
||||
|
||||
export const withEditMode =
|
||||
( { description, editLabel, icon, label }: EditModeConfiguration ) =>
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: EditModeProps< T > ) => {
|
||||
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 && (
|
||||
// Ignoring this TS error for now as it seems that `ProductCategoryControl`
|
||||
// types might be too strict.
|
||||
// @todo Convert `ProductCategoryControl` to TypeScript
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<ProductCategoryControl
|
||||
selected={ [ attributes.categoryId ] }
|
||||
onChange={ (
|
||||
value: WP_REST_API_Category[] = []
|
||||
) => {
|
||||
const id = value[ 0 ] ? value[ 0 ].id : 0;
|
||||
setAttributes( {
|
||||
categoryId: id,
|
||||
mediaId: 0,
|
||||
mediaSrc: '',
|
||||
} );
|
||||
triggerUrlUpdate();
|
||||
} }
|
||||
isSingle
|
||||
/>
|
||||
) }
|
||||
{ name === BLOCK_NAMES.featuredProduct && (
|
||||
<ProductControl
|
||||
selected={
|
||||
attributes.productId
|
||||
? [ attributes.productId ]
|
||||
: []
|
||||
}
|
||||
// `ProductControl` is not yet a TypeScript file and the types
|
||||
// are incorrectly generated for the wrapped HOC, so `showVariation`
|
||||
// doesn't appear in the allowed props
|
||||
// @todo Convert `ProductControl` to TypeScript
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
showVariations
|
||||
onChange={ (
|
||||
value: ProductResponseItem[] = []
|
||||
) => {
|
||||
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,35 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useEffect, useState } from '@wordpress/element';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { EditorBlock } from './types';
|
||||
|
||||
interface EditingImageRequiredProps {
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
type EditingImageProps< T extends EditorBlock< T > > = T &
|
||||
EditingImageRequiredProps;
|
||||
|
||||
export const withEditingImage =
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: EditingImageProps< T > ) => {
|
||||
const [ isEditingImage, setIsEditingImage ] = useState( false );
|
||||
const { isSelected } = props;
|
||||
|
||||
useEffect( () => {
|
||||
setIsEditingImage( false );
|
||||
}, [ isSelected ] );
|
||||
|
||||
return (
|
||||
<Component
|
||||
{ ...props }
|
||||
useEditingImage={ [ isEditingImage, setIsEditingImage ] }
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { BlockAlignment } from '@wordpress/blocks';
|
||||
import { ProductResponseItem, isEmpty } from '@woocommerce/types';
|
||||
import { Icon, Placeholder, Spinner } from '@wordpress/components';
|
||||
import classnames from 'classnames';
|
||||
import { useCallback, useState } from '@wordpress/element';
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
import { useStyleProps } from '@woocommerce/base-hooks';
|
||||
import type { ComponentType, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CallToAction } from './call-to-action';
|
||||
import { ConstrainedResizable } from './constrained-resizable';
|
||||
import { EditorBlock, GenericBlockUIConfig } from './types';
|
||||
import { useBackgroundImage } from './use-background-image';
|
||||
import {
|
||||
dimRatioToClass,
|
||||
getBackgroundImageStyles,
|
||||
getClassPrefixFromName,
|
||||
} from './utils';
|
||||
|
||||
interface WithFeaturedItemConfig extends GenericBlockUIConfig {
|
||||
emptyMessage: string;
|
||||
noSelectionButtonLabel: string;
|
||||
}
|
||||
|
||||
export interface FeaturedItemRequiredAttributes {
|
||||
contentAlign: BlockAlignment;
|
||||
dimRatio: number;
|
||||
focalPoint: { x: number; y: number };
|
||||
hasParallax: boolean;
|
||||
imageFit: 'cover' | 'none';
|
||||
isRepeated: boolean;
|
||||
linkText: string;
|
||||
mediaId: number;
|
||||
mediaSrc: string;
|
||||
minHeight: number;
|
||||
overlayColor: string;
|
||||
overlayGradient: string;
|
||||
showDesc: boolean;
|
||||
showPrice: boolean;
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
interface FeaturedCategoryRequiredAttributes
|
||||
extends FeaturedItemRequiredAttributes {
|
||||
categoryId: number | 'preview';
|
||||
productId: never;
|
||||
}
|
||||
|
||||
interface FeaturedProductRequiredAttributes
|
||||
extends FeaturedItemRequiredAttributes {
|
||||
categoryId: never;
|
||||
productId: number | 'preview';
|
||||
}
|
||||
|
||||
interface FeaturedItemRequiredProps< T > {
|
||||
attributes: (
|
||||
| FeaturedCategoryRequiredAttributes
|
||||
| FeaturedProductRequiredAttributes
|
||||
) &
|
||||
EditorBlock< T >[ 'attributes' ] & {
|
||||
// This is hardcoded because border and color are not yet included
|
||||
// in Gutenberg's official types.
|
||||
style: {
|
||||
border?: { radius?: number };
|
||||
color?: { text?: string };
|
||||
};
|
||||
textColor?: string;
|
||||
};
|
||||
isLoading: boolean;
|
||||
setAttributes: ( attrs: Partial< FeaturedItemRequiredAttributes > ) => void;
|
||||
useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
|
||||
}
|
||||
|
||||
interface FeaturedCategoryProps< T > extends FeaturedItemRequiredProps< T > {
|
||||
category: WP_REST_API_Category;
|
||||
product: never;
|
||||
}
|
||||
|
||||
interface FeaturedProductProps< T > extends FeaturedItemRequiredProps< T > {
|
||||
category: never;
|
||||
product: ProductResponseItem;
|
||||
}
|
||||
|
||||
type FeaturedItemProps< T extends EditorBlock< T > > =
|
||||
| ( T & FeaturedCategoryProps< T > )
|
||||
| ( T & FeaturedProductProps< T > );
|
||||
|
||||
export const withFeaturedItem =
|
||||
( {
|
||||
emptyMessage,
|
||||
icon,
|
||||
label,
|
||||
noSelectionButtonLabel,
|
||||
}: WithFeaturedItemConfig ) =>
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: FeaturedItemProps< T > ) => {
|
||||
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 as string }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNoItemButton = () => {
|
||||
return (
|
||||
<>
|
||||
<p>{ emptyMessage }</p>
|
||||
<div style={ { flexBasis: '100%', height: '0' } }></div>
|
||||
<button
|
||||
type="button"
|
||||
className="components-button is-secondary"
|
||||
onClick={ () => setAttributes( { editMode: true } ) }
|
||||
>
|
||||
{ noSelectionButtonLabel }
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNoItem = () => (
|
||||
<Placeholder
|
||||
className={ className }
|
||||
icon={ <Icon icon={ icon } /> }
|
||||
label={ label }
|
||||
>
|
||||
{ isLoading ? <Spinner /> : renderNoItemButton() }
|
||||
</Placeholder>
|
||||
);
|
||||
|
||||
const styleProps = useStyleProps( attributes );
|
||||
|
||||
const renderItem = () => {
|
||||
const {
|
||||
contentAlign,
|
||||
dimRatio,
|
||||
focalPoint,
|
||||
hasParallax,
|
||||
isRepeated,
|
||||
imageFit,
|
||||
minHeight,
|
||||
overlayColor,
|
||||
overlayGradient,
|
||||
showDesc,
|
||||
showPrice,
|
||||
style,
|
||||
textColor,
|
||||
} = attributes;
|
||||
|
||||
const containerClass = 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`,
|
||||
styleProps.className
|
||||
);
|
||||
|
||||
const containerStyle = {
|
||||
borderRadius: style?.border?.radius,
|
||||
color: textColor
|
||||
? `var(--wp--preset--color--${ textColor })`
|
||||
: style?.color?.text,
|
||||
boxSizing: 'border-box',
|
||||
minHeight,
|
||||
...styleProps.style,
|
||||
};
|
||||
|
||||
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={ containerClass } style={ containerStyle }>
|
||||
<div className={ `${ className }__wrapper` }>
|
||||
<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.currentTarget
|
||||
?.naturalHeight,
|
||||
width: e.currentTarget
|
||||
?.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() }
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from '@wordpress/element';
|
||||
import { WP_REST_API_Category } from 'wp-types';
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { EditorBlock } from './types';
|
||||
|
||||
interface WithUpdateButtonRequiredAttributes {
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
interface WithUpdateButtonAttributes< T > {
|
||||
attributes: WithUpdateButtonRequiredAttributes &
|
||||
EditorBlock< T >[ 'attributes' ];
|
||||
}
|
||||
|
||||
interface WithUpdateButtonCategoryProps< T >
|
||||
extends WithUpdateButtonAttributes< T > {
|
||||
category: WP_REST_API_Category;
|
||||
product: never;
|
||||
}
|
||||
|
||||
interface WithUpdateButtonProductProps< T >
|
||||
extends WithUpdateButtonAttributes< T > {
|
||||
category: never;
|
||||
product: ProductResponseItem;
|
||||
}
|
||||
|
||||
type WithUpdateButtonProps< T extends EditorBlock< T > > =
|
||||
| ( T & WithUpdateButtonCategoryProps< T > )
|
||||
| ( T & WithUpdateButtonProductProps< T > );
|
||||
|
||||
export const withUpdateButtonAttributes =
|
||||
< T extends EditorBlock< T > >( Component: ComponentType< T > ) =>
|
||||
( props: WithUpdateButtonProps< T > ) => {
|
||||
const [ doUrlUpdate, setDoUrlUpdate ] = useState( false );
|
||||
const { attributes, category, clientId, product } = props;
|
||||
const item = category || product;
|
||||
|
||||
const { editMode } = attributes;
|
||||
const permalink =
|
||||
( item as WP_REST_API_Category )?.link ||
|
||||
( item as ProductResponseItem )?.permalink;
|
||||
|
||||
const block = useSelect( ( select ) => {
|
||||
return select( 'core/block-editor' ).getBlock( clientId );
|
||||
} );
|
||||
const innerBlock = block?.innerBlocks[ 0 ]?.innerBlocks[ 0 ];
|
||||
const buttonBlockId = innerBlock?.clientId || '';
|
||||
const currentButtonAttributes = useMemo(
|
||||
() => innerBlock?.attributes || {},
|
||||
[ innerBlock ]
|
||||
);
|
||||
const { url } = currentButtonAttributes;
|
||||
|
||||
const { updateBlockAttributes } = useDispatch( 'core/block-editor' );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
doUrlUpdate &&
|
||||
buttonBlockId &&
|
||||
! editMode &&
|
||||
permalink &&
|
||||
url &&
|
||||
permalink !== url
|
||||
) {
|
||||
updateBlockAttributes( buttonBlockId, {
|
||||
url: permalink,
|
||||
} );
|
||||
setDoUrlUpdate( false );
|
||||
}
|
||||
}, [
|
||||
buttonBlockId,
|
||||
doUrlUpdate,
|
||||
editMode,
|
||||
permalink,
|
||||
updateBlockAttributes,
|
||||
url,
|
||||
] );
|
||||
|
||||
const triggerUrlUpdate = () => setDoUrlUpdate( true );
|
||||
|
||||
return <Component { ...props } triggerUrlUpdate={ triggerUrlUpdate } />;
|
||||
};
|
||||
Reference in New Issue
Block a user