8889841cPKE[c2with-featured-item.tsxnu[/* 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 ( ); }; const renderNoItemButton = () => { return ( <>

{ emptyMessage }

); }; const renderNoItem = () => ( } label={ label } > { isLoading ? : renderNoItemButton() } ); 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 ( <>
{ backgroundImageSrc && ( isImgElement ? ( { { setBackgroundImageSize( { height: e.currentTarget ?.naturalHeight, width: e.currentTarget ?.naturalWidth, } ); } } /> ) : (
) ) }

{ ! isEmpty( product?.variation ) && (

) } { showDesc && (
) } { showPrice && (
) }
{ renderButton() }
); }; if ( isEditingImage ) { return ( ); } return ( <> { item ? renderItem() : renderNoItem() } ); }; PKE[y constants.tsnu[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; PKE[tcall-to-action.tsxnu[/** * 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' ? (
) : ( ); }; PKE[TQLLedit.tsxnu[/** * 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 (
); }; } PKE[lutils.tsnu[/** * 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 ) }`; } PKE[J33featured-category/block.jsonnu[{ "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" } PKE[(>ߍfeatured-category/utils.tsnu[/** * 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; } PKE[pPfeatured-category/example.tsnu[/** * 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; PKE[K pfeatured-category/style.scssnu[@import "../style"; .wp-block-woocommerce-featured-category { @extend %wp-block-featured-item; } .wc-block-featured-category { @include wc-block-featured-item(); } PKE[featured-category/index.tsxnu[/** * 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: ( ), }, } ); PKE[=uM}}featured-category/block.tsxnu[/** * 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 ), ] )( () => <> ); PKE[w,מ~~featured-category/editor.scssnu[@import "../style"; .wp-block-woocommerce-featured-category { @extend %with-media-controls; @extend %with-resizable-box; } PKE[dtuse-background-image.tsnu[/** * 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 }; } PKE[Аtypes.tsnu[/** * 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' ); } PKE[vF#F#inspector-controls.tsxnu[/* 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 ( setAttributes( { showDesc: ! showDesc } ) } /> { contentPanel } { !! backgroundImageSrc && ( <> { focalPointPickerExists && ( { setAttributes( { hasParallax: ! hasParallax, } ); } } /> { setAttributes( { isRepeated: ! isRepeated, } ); } } /> { ! isRepeated && ( { __( 'Select “Cover” to have the image automatically fit its container.', 'woo-gutenberg-products-block' ) } { __( 'This may affect your ability to freely move the focal point of the image.', 'woo-gutenberg-products-block' ) } } label={ __( 'Image fit', 'woo-gutenberg-products-block' ) } value={ imageFit } onChange={ ( value: ImageFit ) => setAttributes( { imageFit: value, } ) } > ) } setAttributes( { focalPoint: value, } ) } /> { isImgElement && ( { setAttributes( { alt: value } ); } } help={ <> { __( 'Describe the purpose of the image', 'woo-gutenberg-products-block' ) } } /> ) } ) } setAttributes( { overlayColor: value } ), onGradientChange: ( value: string ) => { setGradient( value ); setAttributes( { overlayGradient: value, } ); }, label: __( 'Color', 'woo-gutenberg-products-block' ), }, ] } > setAttributes( { dimRatio: value as number } ) } min={ 0 } max={ 100 } step={ 10 } required /> ) } ); }; 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 ? ( setAttributes( { showPrice: ! showPrice, } ) } /> ) : undefined; return ( <> ); }; PKE[with-edit-mode.tsxnu[/** * 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 ( } label={ label } className={ className } > { description }
{ 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 { const id = value[ 0 ] ? value[ 0 ].id : 0; setAttributes( { categoryId: id, mediaId: 0, mediaSrc: '', } ); triggerUrlUpdate(); } } isSingle /> ) } { name === BLOCK_NAMES.featuredProduct && ( { const id = value[ 0 ] ? value[ 0 ].id : 0; setAttributes( { productId: id, mediaId: 0, mediaSrc: '', } ); triggerUrlUpdate(); } } /> ) }
); } return ; }; PKE[y| | !with-update-button-attributes.tsxnu[/** * 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 ; }; PKE[VVfeatured-product/block.jsonnu[{ "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" } PKE[J =featured-product/example.tsnu[/** * 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; PKE[z~  featured-product/style.scssnu[@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; } } } PKE[L#{featured-product/index.tsxnu[/** * 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: ( ), }, } ); PKE[(CCfeatured-product/block.tsxnu[/** * 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 ), ] )( () => <> ); PKE[yfeatured-product/editor.scssnu[@import "../style"; .wp-block-woocommerce-featured-product { @extend %with-media-controls; @extend %with-resizable-box; &__message { margin-bottom: 16px; } } PKE[EY+zwith-editing-image.tsxnu[/** * 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 ( ); }; PKE[constrained-resizable.tsxnu[/** * 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 ( { onResize?.( ...args ); setIsResizing( false ); } } { ...props } /> ); }; PKE[v! ! register.tsxnu[// 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: () => , ...settings, } ); } PKE[̺image-editor.tsxnu[/* 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 ( { setAttributes( { mediaId: id, mediaSrc: url } ); } } isEditing={ isEditingImage } onFinishEditing={ () => setIsEditingImage( false ) } > ); } return ( { 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 (
); } return ; }; PKE[3 style.scssnu[@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; } } PKE[9EMblock-controls.tsxnu[/** * 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 ( { setAttributes( { contentAlign: nextAlign } ); } } /> { backgroundImageSrc && ! isEditingImage && ( setIsEditingImage( true ) } icon={ crop } label={ cropLabel } /> ) } { 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, }, ] } /> ); }; 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 ( <> ); }; PKE[ ,,with-api-error.tsxnu[/** * 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 ( ); } return ; }; PKE[c2with-featured-item.tsxnu[PKE[y > constants.tsnu[PKE[tV!call-to-action.tsxnu[PKE[TQLL&edit.tsxnu[PKE[l)utils.tsnu[PKE[J331featured-category/block.jsonnu[PKE[(>ߍL9featured-category/utils.tsnu[PKE[pP#<featured-category/example.tsnu[PKE[K pP>featured-category/style.scssnu[PKE[D?featured-category/index.tsxnu[PKE[=uM}}Afeatured-category/block.tsxnu[PKE[w,מ~~\Ifeatured-category/editor.scssnu[PKE[dt'Juse-background-image.tsnu[PKE[АPtypes.tsnu[PKE[vF#F#Sinspector-controls.tsxnu[PKE[Bwwith-edit-mode.tsxnu[PKE[y| | !with-update-button-attributes.tsxnu[PKE[VVfeatured-product/block.jsonnu[PKE[J =featured-product/example.tsnu[PKE[z~  featured-product/style.scssnu[PKE[L#{featured-product/index.tsxnu[PKE[(CC%featured-product/block.tsxnu[PKE[yfeatured-product/editor.scssnu[PKE[EY+zwith-editing-image.tsxnu[PKE[constrained-resizable.tsxnu[PKE[v! ! _register.tsxnu[PKE[̺image-editor.tsxnu[PKE[3  style.scssnu[PKE[9EMblock-controls.tsxnu[PKE[ ,,with-api-error.tsxnu[PK N