From b1bc51e3f022b65db72283676239b3b49a05dfa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alba=20Rinc=C3=B3n?= Date: Mon, 3 Jul 2023 11:48:50 +0200 Subject: [PATCH] Add new `Product Rating Stars` block (https://github.com/woocommerce/woocommerce-blocks/pull/10005) * Add new `Product Rating Stars` block * Make block experimental * Fix dep --- .../assets/js/atomic/blocks/component-init.js | 9 + .../assets/js/atomic/blocks/index.js | 1 + .../product-elements/rating-stars/block.json | 38 ++++ .../product-elements/rating-stars/block.tsx | 156 +++++++++++++++++ .../rating-stars/constants.tsx | 20 +++ .../product-elements/rating-stars/edit.tsx | 75 ++++++++ .../product-elements/rating-stars/index.tsx | 36 ++++ .../product-elements/rating-stars/style.scss | 106 ++++++++++++ .../product-elements/rating-stars/support.ts | 32 ++++ .../product-elements/rating-stars/types.ts | 7 + .../woocommerce-blocks/bin/webpack-entries.js | 3 + .../src/BlockTypes/ProductRatingStars.php | 163 ++++++++++++++++++ .../src/BlockTypesController.php | 1 + 13 files changed, 647 insertions(+) create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.json create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/constants.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/edit.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/index.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/style.scss create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/support.ts create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/types.ts create mode 100644 plugins/woocommerce-blocks/src/BlockTypes/ProductRatingStars.php diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/component-init.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/component-init.js index 8879acd911c..0a9912c2c89 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/component-init.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/component-init.js @@ -45,6 +45,15 @@ registerBlockComponent( { ), } ); +registerBlockComponent( { + blockName: 'woocommerce/product-rating-stars', + component: lazy( () => + import( + /* webpackChunkName: "product-rating-stars" */ './product-elements/rating-stars/block' + ) + ), +} ); + registerBlockComponent( { blockName: 'woocommerce/product-button', component: lazy( () => diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/index.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/index.js index 4346815ed1e..b5fd3f6d299 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/index.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/index.js @@ -5,6 +5,7 @@ import './product-elements/title'; import './product-elements/price'; import './product-elements/image'; import './product-elements/rating'; +import './product-elements/rating-stars'; import './product-elements/button'; import './product-elements/summary'; import './product-elements/sale-badge'; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.json b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.json new file mode 100644 index 00000000000..eb48c3c127c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.json @@ -0,0 +1,38 @@ +{ + "name": "woocommerce/product-rating-stars", + "version": "1.0.0", + "icon": "info", + "title": "Product Rating Stars", + "description": "Display the average rating of a product with stars", + "attributes": { + "productId": { + "type": "number", + "default": 0 + }, + "isDescendentOfQueryLoop": { + "type": "boolean", + "default": false + }, + "textAlign": { + "type": "string", + "default": "" + }, + "isDescendentOfSingleProductBlock": { + "type": "boolean", + "default": false + }, + "isDescendentOfSingleProductTemplate": { + "type": "boolean", + "default": false + } + }, + "usesContext": [ "query", "queryId", "postId" ], + "category": "woocommerce", + "keywords": [ "WooCommerce" ], + "supports": { + "align": true + }, + "textdomain": "woo-gutenberg-products-block", + "apiVersion": 2, + "$schema": "https://schemas.wp.org/trunk/block.json" +} diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.tsx b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.tsx new file mode 100644 index 00000000000..15d65db132f --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/block.tsx @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import classnames from 'classnames'; +import { + useInnerBlockLayoutContext, + useProductDataContext, +} from '@woocommerce/shared-context'; +import { useStyleProps } from '@woocommerce/base-hooks'; +import { withProductDataContext } from '@woocommerce/shared-hocs'; +import { isNumber, ProductResponseItem } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import './style.scss'; + +type RatingProps = { + reviews: number; + rating: number; + parentClassName?: string; +}; + +const getAverageRating = ( + product: Omit< ProductResponseItem, 'average_rating' > & { + average_rating: string; + } +) => { + const rating = parseFloat( product.average_rating ); + + return Number.isFinite( rating ) && rating > 0 ? rating : 0; +}; + +const getRatingCount = ( product: ProductResponseItem ) => { + const count = isNumber( product.review_count ) + ? product.review_count + : parseInt( product.review_count, 10 ); + + return Number.isFinite( count ) && count > 0 ? count : 0; +}; + +const getStarStyle = ( rating: number ) => ( { + width: ( rating / 5 ) * 100 + '%', +} ); + +const NoRating = ( { parentClassName }: { parentClassName: string } ) => { + const starStyle = getStarStyle( 0 ); + + return ( +
+
+ +
+ { __( 'No Reviews', 'woo-gutenberg-products-block' ) } +
+ ); +}; + +const Rating = ( props: RatingProps ): JSX.Element => { + const { rating, reviews, parentClassName } = props; + + const starStyle = getStarStyle( rating ); + + const ratingText = sprintf( + /* translators: %f is referring to the average rating value */ + __( 'Rated %f out of 5', 'woo-gutenberg-products-block' ), + rating + ); + + const ratingHTML = { + __html: sprintf( + /* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */ + _n( + 'Rated %1$s out of 5 based on %2$s customer rating', + 'Rated %1$s out of 5 based on %2$s customer ratings', + reviews, + 'woo-gutenberg-products-block' + ), + sprintf( '%f', rating ), + sprintf( '%d', reviews ) + ), + }; + return ( +
+ +
+ ); +}; + +interface ProductRatingStarsProps { + className?: string; + textAlign?: string; + isDescendentOfSingleProductBlock: boolean; + isDescendentOfQueryLoop: boolean; + postId: number; + productId: number; + shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean; +} + +export const Block = ( props: ProductRatingStarsProps ): JSX.Element | null => { + const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } = + props; + const styleProps = useStyleProps( props ); + const { parentClassName } = useInnerBlockLayoutContext(); + const { product } = useProductDataContext(); + const rating = getAverageRating( product ); + const reviews = getRatingCount( product ); + + const className = classnames( + styleProps.className, + 'wc-block-components-product-rating', + { + [ `${ parentClassName }__product-rating` ]: parentClassName, + [ `has-text-align-${ textAlign }` ]: textAlign, + } + ); + const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? ( + + ) : null; + + const content = reviews ? ( + + ) : ( + mockedRatings + ); + + return ( +
+
+ { content } +
+
+ ); +}; + +export default withProductDataContext( Block ); diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/constants.tsx b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/constants.tsx new file mode 100644 index 00000000000..890cb3e768d --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/constants.tsx @@ -0,0 +1,20 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Icon, starFilled } from '@wordpress/icons'; + +export const BLOCK_TITLE: string = __( + 'Product Rating Stars', + 'woo-gutenberg-products-block' +); +export const BLOCK_ICON: JSX.Element = ( + +); +export const BLOCK_DESCRIPTION: string = __( + 'Display the average rating of a product with stars', + 'woo-gutenberg-products-block' +); diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/edit.tsx b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/edit.tsx new file mode 100644 index 00000000000..7094c80853d --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/edit.tsx @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { + AlignmentToolbar, + BlockControls, + useBlockProps, +} from '@wordpress/block-editor'; +import type { BlockEditProps } from '@wordpress/blocks'; +import { useEffect } from '@wordpress/element'; +import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types'; + +/** + * Internal dependencies + */ +import Block from './block'; +import { BlockAttributes } from './types'; +import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block'; +import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template'; + +const Edit = ( + props: BlockEditProps< BlockAttributes > & { context: Context } +): JSX.Element => { + const { attributes, setAttributes, context } = props; + const blockProps = useBlockProps( { + className: 'wp-block-woocommerce-product-rating', + } ); + const blockAttrs = { + ...attributes, + ...context, + shouldDisplayMockedReviewsWhenProductHasNoReviews: true, + }; + const isDescendentOfQueryLoop = Number.isFinite( context.queryId ); + const { isDescendentOfSingleProductBlock } = + useIsDescendentOfSingleProductBlock( { + blockClientId: blockProps?.id, + } ); + let { isDescendentOfSingleProductTemplate } = + useIsDescendentOfSingleProductTemplate(); + + if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) { + isDescendentOfSingleProductTemplate = false; + } + + useEffect( () => { + setAttributes( { + isDescendentOfQueryLoop, + isDescendentOfSingleProductBlock, + isDescendentOfSingleProductTemplate, + } ); + }, [ + setAttributes, + isDescendentOfQueryLoop, + isDescendentOfSingleProductBlock, + isDescendentOfSingleProductTemplate, + ] ); + + return ( + <> + + { + setAttributes( { textAlign: newAlign || '' } ); + } } + /> + +
+ +
+ + ); +}; + +export default Edit; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/index.tsx b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/index.tsx new file mode 100644 index 00000000000..a95a7f7977c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/index.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { BlockConfiguration } from '@wordpress/blocks'; +import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils'; +import { isExperimentalBuild } from '@woocommerce/block-settings'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import sharedConfig from '../shared/config'; +import { supports } from './support'; +import { BLOCK_ICON } from './constants'; + +const blockConfig: BlockConfiguration = { + ...sharedConfig, + ancestor: [ + 'woocommerce/all-products', + 'woocommerce/single-product', + 'core/post-template', + 'woocommerce/product-template', + ], + icon: { src: BLOCK_ICON }, + supports, + edit, +}; + +if ( isExperimentalBuild() ) { + registerBlockSingleProductTemplate( { + blockName: 'woocommerce/product-rating-stars', + blockMetadata: metadata, + blockSettings: blockConfig, + } ); +} diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/style.scss b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/style.scss new file mode 100644 index 00000000000..987a63c822f --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/style.scss @@ -0,0 +1,106 @@ +.wc-block-components-product-rating { + display: block; + line-height: 1; + + &__stars { + display: inline-block; + overflow: hidden; + position: relative; + width: 5.3em; + height: 1.618em; + line-height: 1.618; + font-size: 1em; + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */ + font-family: star; + font-weight: 400; + + &::before { + content: "\53\53\53\53\53"; + top: 0; + left: 0; + right: 0; + position: absolute; + opacity: 0.5; + color: inherit; + white-space: nowrap; + } + span { + overflow: hidden; + top: 0; + left: 0; + right: 0; + position: absolute; + color: inherit; + padding-top: 1.5em; + } + span::before { + content: "\53\53\53\53\53"; + top: 0; + left: 0; + right: 0; + position: absolute; + color: inherit; + white-space: nowrap; + } + } + + &__link { + display: inline-block; + height: 1.618em; + width: 100%; + text-align: inherit; + @include font-size(small); + } + + .wc-block-all-products & { + margin-top: 0; + margin-bottom: $gap-small; + } + + &__norating-container { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: $gap-smaller; + } + + &__norating { + display: inline-block; + overflow: hidden; + position: relative; + width: 1.5em; + height: 1.618em; + line-height: 1.618; + font-size: 1em; + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */ + font-family: star; + font-weight: 400; + -webkit-text-stroke: 2px var(--wp--preset--color--black, #000); + &::before { + content: "\53"; + top: 0; + left: 0; + right: 0; + position: absolute; + color: transparent; + white-space: nowrap; + text-align: center; + } + } +} + +.wp-block-woocommerce-single-product { + .wc-block-components-product-rating__stars { + margin: 0; + } +} + +.wc-block-all-products, +.wp-block-query { + .is-loading { + .wc-block-components-product-rating { + @include placeholder(); + width: 7em; + } + } +} diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/support.ts b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/support.ts new file mode 100644 index 00000000000..59b63c04e2c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/support.ts @@ -0,0 +1,32 @@ +/* eslint-disable @wordpress/no-unsafe-wp-apis */ +/** + * External dependencies + */ +import { isFeaturePluginBuild } from '@woocommerce/block-settings'; +import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor'; + +export const supports = { + ...( isFeaturePluginBuild() && { + color: { + text: true, + background: false, + link: false, + __experimentalSkipSerialization: true, + }, + spacing: { + margin: true, + padding: true, + }, + typography: { + fontSize: true, + __experimentalSkipSerialization: true, + }, + __experimentalSelector: '.wc-block-components-product-rating', + } ), + ...( ! isFeaturePluginBuild() && + typeof __experimentalGetSpacingClassesAndStyles === 'function' && { + spacing: { + margin: true, + }, + } ), +}; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/types.ts b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/types.ts new file mode 100644 index 00000000000..f6f8e5296ac --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/rating-stars/types.ts @@ -0,0 +1,7 @@ +export interface BlockAttributes { + productId: number; + isDescendentOfQueryLoop: boolean; + isDescendentOfSingleProductBlock: boolean; + isDescendentOfSingleProductTemplate: boolean; + textAlign: string; +} diff --git a/plugins/woocommerce-blocks/bin/webpack-entries.js b/plugins/woocommerce-blocks/bin/webpack-entries.js index 996f24b89a9..e0cce8e934c 100644 --- a/plugins/woocommerce-blocks/bin/webpack-entries.js +++ b/plugins/woocommerce-blocks/bin/webpack-entries.js @@ -58,6 +58,9 @@ const blocks = { 'product-top-rated': {}, 'products-by-attribute': {}, 'rating-filter': {}, + 'product-rating-stars': { + isExperimental: true, + }, 'reviews-by-category': { customDir: 'reviews/reviews-by-category', }, diff --git a/plugins/woocommerce-blocks/src/BlockTypes/ProductRatingStars.php b/plugins/woocommerce-blocks/src/BlockTypes/ProductRatingStars.php new file mode 100644 index 00000000000..d42325f8de0 --- /dev/null +++ b/plugins/woocommerce-blocks/src/BlockTypes/ProductRatingStars.php @@ -0,0 +1,163 @@ + + array( + 'text' => true, + 'background' => false, + 'link' => false, + '__experimentalSkipSerialization' => true, + ), + 'typography' => + array( + 'fontSize' => true, + '__experimentalSkipSerialization' => true, + ), + 'spacing' => + array( + 'margin' => true, + 'padding' => true, + '__experimentalSkipSerialization' => true, + ), + '__experimentalSelector' => '.wc-block-components-product-rating', + ); + } + + /** + * Overwrite parent method to prevent script registration. + * + * It is necessary to register and enqueues assets during the render + * phase because we want to load assets only if the block has the content. + */ + protected function register_block_type_assets() { + return null; + } + + /** + * Register the context. + */ + protected function get_block_type_uses_context() { + return [ 'query', 'queryId', 'postId' ]; + } + + /** + * Include and render the block. + * + * @param array $attributes Block attributes. Default empty array. + * @param string $content Block content. Default empty string. + * @param WP_Block $block Block instance. + * @return string Rendered block type output. + */ + protected function render( $attributes, $content, $block ) { + if ( ! empty( $content ) ) { + parent::register_block_type_assets(); + $this->register_chunk_translations( [ $this->block_name ] ); + return $content; + } + + $post_id = $block->context['postId']; + $product = wc_get_product( $post_id ); + + if ( $product ) { + $product_reviews_count = $product->get_review_count(); + $product_rating = $product->get_average_rating(); + + $styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); + $text_align_styles_and_classes = StyleAttributesUtils::get_text_align_class_and_style( $attributes ); + + /** + * Filter the output from wc_get_rating_html. + * + * @param string $html Star rating markup. Default empty string. + * @param float $rating Rating being shown. + * @param int $count Total number of ratings. + * @return string + */ + $filter_rating_html = function( $html, $rating, $count ) use ( $product_rating, $product_reviews_count ) { + $product_permalink = get_permalink(); + $reviews_count = $count; + $average_rating = $rating; + + if ( $product_rating ) { + $average_rating = $product_rating; + } + + if ( $product_reviews_count ) { + $reviews_count = $product_reviews_count; + } + + if ( 0 < $average_rating || false === $product_permalink ) { + /* translators: %s: rating */ + $label = sprintf( __( 'Rated %s out of 5', 'woo-gutenberg-products-block' ), $average_rating ); + $html = sprintf( + '
+ +
+ ', + esc_attr( $label ), + wc_get_star_rating_html( $average_rating, $reviews_count ) + ); + } else { + $html = ''; + } + + return $html; + }; + + add_filter( + 'woocommerce_product_get_rating_html', + $filter_rating_html, + 10, + 3 + ); + + $rating_html = wc_get_rating_html( $product->get_average_rating() ); + + remove_filter( + 'woocommerce_product_get_rating_html', + $filter_rating_html, + 10 + ); + + return sprintf( + '
+ %4$s +
', + esc_attr( $text_align_styles_and_classes['class'] ?? '' ), + esc_attr( $styles_and_classes['classes'] ), + esc_attr( $styles_and_classes['styles'] ?? '' ), + $rating_html + ); + } + } +} diff --git a/plugins/woocommerce-blocks/src/BlockTypesController.php b/plugins/woocommerce-blocks/src/BlockTypesController.php index 5e1fdc677db..b26e52f3457 100644 --- a/plugins/woocommerce-blocks/src/BlockTypesController.php +++ b/plugins/woocommerce-blocks/src/BlockTypesController.php @@ -226,6 +226,7 @@ final class BlockTypesController { if ( Package::feature()->is_experimental_build() ) { $block_types[] = 'ProductCollection'; + $block_types[] = 'ProductRatingStars'; $block_types[] = 'ProductTemplate'; }