diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/button/index.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/button/index.js index cf5caa95e47..a4f55d258d6 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/button/index.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/button/index.js @@ -13,7 +13,7 @@ import { ProductButton } from '@woocommerce/atomic-components/product'; import sharedConfig from '../shared-config'; const blockConfig = { - title: __( 'Product Button', 'woo-gutenberg-products-block' ), + title: __( 'Add to Cart Button', 'woo-gutenberg-products-block' ), description: __( 'Display a call to action button which either adds the product to the cart, or links to the product page.', 'woo-gutenberg-products-block' diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/summary/index.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/summary/index.js index 5bf82ea1b69..105f1d97540 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/summary/index.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product/summary/index.js @@ -14,7 +14,7 @@ import sharedConfig from '../shared-config'; const blockConfig = { title: __( 'Product Summary', 'woo-gutenberg-products-block' ), description: __( - 'Display the short description of a product.', + 'Display a short description about a product.', 'woo-gutenberg-products-block' ), icon: { diff --git a/plugins/woocommerce-blocks/assets/js/atomic/components/product/summary/index.js b/plugins/woocommerce-blocks/assets/js/atomic/components/product/summary/index.js index 374ac48a9fc..38d63a581c3 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/components/product/summary/index.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/components/product/summary/index.js @@ -7,7 +7,7 @@ import { useProductLayoutContext } from '@woocommerce/base-context/product-layou const ProductSummary = ( { className, product } ) => { const { layoutStyleClassPrefix } = useProductLayoutContext(); - if ( ! product.description ) { + if ( ! product.summary ) { return null; } @@ -18,7 +18,7 @@ const ProductSummary = ( { className, product } ) => { `${ layoutStyleClassPrefix }__product-summary` ) } dangerouslySetInnerHTML={ { - __html: product.description, + __html: product.summary, } } /> ); diff --git a/plugins/woocommerce-blocks/assets/js/previews/cart-items.js b/plugins/woocommerce-blocks/assets/js/previews/cart-items.js index e86a600935d..873e2572522 100644 --- a/plugins/woocommerce-blocks/assets/js/previews/cart-items.js +++ b/plugins/woocommerce-blocks/assets/js/previews/cart-items.js @@ -17,7 +17,13 @@ export const previewCartItems = [ id: 1, quantity: 2, name: __( 'Beanie', 'woo-gutenberg-products-block' ), - description: __( 'Warm hat for winter' ), + summary: __( 'Warm hat for winter', 'woo-gutenberg-products-block' ), + short_description: __( + 'Warm hat for winter', + 'woo-gutenberg-products-block' + ), + description: + 'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.', sku: 'woo-beanie', permalink: 'https://example.org', low_stock_remaining: 2, @@ -61,7 +67,16 @@ export const previewCartItems = [ id: 2, quantity: 1, name: __( 'Cap', 'woo-gutenberg-products-block' ), - description: __( 'Lightweight baseball cap' ), + summary: __( + 'Lightweight baseball cap', + 'woo-gutenberg-products-block' + ), + short_description: __( + 'Lightweight baseball cap', + 'woo-gutenberg-products-block' + ), + description: + 'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.', sku: 'woo-cap', permalink: 'https://example.org', images: [ diff --git a/plugins/woocommerce-blocks/assets/js/previews/products.js b/plugins/woocommerce-blocks/assets/js/previews/products.js index 6ca2bd355d2..45d786489a0 100644 --- a/plugins/woocommerce-blocks/assets/js/previews/products.js +++ b/plugins/woocommerce-blocks/assets/js/previews/products.js @@ -8,6 +8,11 @@ import { __ } from '@wordpress/i18n'; */ import productPicture from './product-image'; +const shortDescription = __( + 'Fly your WordPress banner with this beauty! Deck out your office space or add it to your kids walls. This banner will spruce up any space it’s hung!', + 'woo-gutenberg-products-block' +); + export const previewProducts = [ { id: 1, @@ -15,10 +20,10 @@ export const previewProducts = [ variation: '', permalink: 'https://example.org', sku: 'wp-pennant', - description: __( - 'Fly your WordPress banner with this beauty! Deck out your office space or add it to your kids walls. This banner will spruce up any space it’s hung!', - 'woo-gutenberg-products-block' - ), + summary: shortDescription, + short_description: shortDescription, + description: + 'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.', price: '7.99', price_html: '$7.99', diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php index 037484433d6..5e78239658f 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php @@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductImages; +use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductSummary; use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock; /** @@ -63,11 +64,20 @@ class CartItemSchema extends AbstractSchema { 'context' => [ 'view', 'edit' ], 'readonly' => true, ], - 'short_description' => [ - 'description' => __( 'Product short description or excerpt from full description.', 'woo-gutenberg-products-block' ), + 'summary' => [ + 'description' => __( 'A short summary (or excerpt from the full description) for the product in HTML format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'short_description' => [ + 'description' => __( 'Product short description in HTML format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'description' => [ + 'description' => __( 'Product full description in HTML format.', 'woo-gutenberg-products-block' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], - 'readonly' => true, ], 'sku' => [ 'description' => __( 'Stock keeping unit, if applicable.', 'woo-gutenberg-products-block' ), @@ -214,17 +224,14 @@ class CartItemSchema extends AbstractSchema { public function get_item_response( $cart_item ) { $product = $cart_item['data']; - $short_description = apply_filters( 'woocommerce_short_description', $product->get_short_description() ? $product->get_short_description() : $product->get_description(), 400 ); - $short_description = wp_filter_nohtml_kses( $short_description ); - $short_description = strip_shortcodes( $short_description ); - $short_description = normalize_whitespace( $short_description ); - return [ 'key' => $cart_item['key'], 'id' => $product->get_id(), 'quantity' => wc_stock_amount( $cart_item['quantity'] ), 'name' => $this->prepare_html_response( $product->get_title() ), - 'short_description' => $this->prepare_html_response( $short_description ), + 'summary' => $this->prepare_html_response( ( new ProductSummary( $product ) )->get_summary( 150 ) ), + 'short_description' => $this->prepare_html_response( wc_format_content( $product->get_short_description() ) ), + 'description' => $this->prepare_html_response( wc_format_content( $product->get_description() ) ), 'sku' => $this->prepare_html_response( $product->get_sku() ), 'low_stock_remaining' => $this->get_low_stock_remaining( $product ), 'permalink' => $product->get_permalink(), diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php index f2cacfd9a67..9c4616a1480 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php @@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductImages; +use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductSummary; /** * ProductSchema class. @@ -54,8 +55,18 @@ class ProductSchema extends AbstractSchema { 'context' => [ 'view', 'edit' ], 'readonly' => true, ], + 'summary' => [ + 'description' => __( 'A short summary (or excerpt from the full description) for the product in HTML format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'short_description' => [ + 'description' => __( 'Product short description in HTML format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], 'description' => [ - 'description' => __( 'Short description or excerpt from description.', 'woo-gutenberg-products-block' ), + 'description' => __( 'Product full description in HTML format.', 'woo-gutenberg-products-block' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], @@ -238,7 +249,9 @@ class ProductSchema extends AbstractSchema { 'variation' => $this->prepare_html_response( $product->is_type( 'variation' ) ? wc_get_formatted_variation( $product, true, true, false ) : '' ), 'permalink' => $product->get_permalink(), 'sku' => $this->prepare_html_response( $product->get_sku() ), - 'description' => $this->prepare_html_response( apply_filters( 'woocommerce_short_description', $product->get_short_description() ? $product->get_short_description() : wc_trim_string( $product->get_description(), 400 ) ) ), + 'summary' => $this->prepare_html_response( ( new ProductSummary( $product ) )->get_summary( 150 ) ), + 'short_description' => $this->prepare_html_response( wc_format_content( $product->get_short_description() ) ), + 'description' => $this->prepare_html_response( wc_format_content( $product->get_description() ) ), 'on_sale' => $product->is_on_sale(), 'prices' => (object) $this->get_prices( $product ), 'average_rating' => $product->get_average_rating(), diff --git a/plugins/woocommerce-blocks/src/RestApi/Utilities/ProductSummary.php b/plugins/woocommerce-blocks/src/RestApi/Utilities/ProductSummary.php new file mode 100644 index 00000000000..3060d40e495 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/Utilities/ProductSummary.php @@ -0,0 +1,109 @@ +product = $product; + } + + /** + * Return the formatted summary. + * + * @param int $max_words Word limit for summary. + * @return string + */ + public function get_summary( $max_words = 25 ) { + $summary = $this->get_content_for_summary(); + + if ( $max_words && $this->get_word_count( $summary ) > $max_words ) { + $summary = $this->generate_summary( $summary, $max_words ); + } + + return \wc_format_content( $summary ); + } + + /** + * Get description to base summary on from the product object. + * + * @return string + */ + private function get_content_for_summary() { + $content = $this->product->get_short_description(); + + if ( ! $content ) { + $content = $this->product->get_description(); + } + + return $content; + } + + /** + * Get the word count. Based on the logic in `wp_trim_words`. + * + * @param string $content HTML Content. + * @return int Length + */ + private function get_word_count( $content ) { + $content = wp_strip_all_tags( $content ); + + /* + * translators: If your word count is based on single characters (e.g. East Asian characters), + * enter 'characters_excluding_spaces' or 'characters_including_spaces'. Otherwise, enter 'words'. + * Do not translate into your own language. + */ + $type = _x( 'words', 'Word count type. Do not translate!' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + + if ( strpos( $type, 'characters' ) === 0 && preg_match( '/^utf\-?8$/i', get_option( 'blog_charset' ) ) ) { + $content = trim( preg_replace( "/[\n\r\t ]+/", ' ', $content ), ' ' ); + preg_match_all( '/./u', $content, $words_array ); + $words_array = $words_array[0]; + } else { + $words_array = preg_split( "/[\n\r\t ]+/", $content ); + } + + return count( array_filter( $words_array ) ); + } + + /** + * Get the first paragraph, or a short excerpt, from some content. + * + * @param string $content HTML Content. + * @param int $max_words Maximum allowed words for summary. + * @return string + */ + private function generate_summary( $content, $max_words ) { + $content_p = \wpautop( $content ); + + // This gets the first paragraph, if

tags are found. Otherwise returns the entire string. + $paragraph = \strstr( $content_p, '

' ) ? \substr( $content_p, 0, \strpos( $content_p, '

' ) + 4 ) : $content; + + if ( $this->get_word_count( $paragraph ) > $max_words ) { + return \wp_trim_words( $paragraph, $max_words ); + } + + return $paragraph; + } +} diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/Utilities/ProductSummary.php b/plugins/woocommerce-blocks/tests/php/RestApi/Utilities/ProductSummary.php new file mode 100644 index 00000000000..bc0e78500e4 --- /dev/null +++ b/plugins/woocommerce-blocks/tests/php/RestApi/Utilities/ProductSummary.php @@ -0,0 +1,68 @@ +set_description( '

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

' ); + + $class = new ProductSummary( $product ); + // 25 word limit should return 1st para. + $this->assertEquals( "

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

\n", $class->get_summary() ); + // Large limit, should return full description. + $this->assertEquals( "

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

\n

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

\n

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

\n

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

\n", $class->get_summary( 1000 ) ); + // Should return 3 words. + $this->assertEquals( "

Lorem ipsum dolor…

\n", $class->get_summary( 3 ) ); + // Should return 1 word. + $this->assertEquals( "

Lorem…

\n", $class->get_summary( 1 ) ); + + // Test for languages where characters are words. + add_filter( 'gettext_with_context', array( $this, 'gettext_with_context_callback' ), 10, 3 ); + + $product->set_description( '

我不知道这是否行得通。

我是用中文写的说明,因此我们可以测试如何修剪产品摘要中的单词。

' ); + $this->assertEquals( "

我不知道这是否行得通。

\n", $class->get_summary() ); + $this->assertEquals( "

我不知…

\n", $class->get_summary( 3 ) ); + } + + /** + * Adjusts word count type to character based. + * + * @param string $translated Translated string. + * @param string $text Text to translate. + * @param string $context Translation context. + * @return string + */ + public function gettext_with_context_callback( $translated, $text, $context ) { + if ( $text === 'words' && $context === 'Word count type. Do not translate!' ) { + return 'characters'; + } + return $translated; + } +} + + +