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 (
+
+ );
+};
+
+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';
}