Commit ccb63ed78df for woocommerce
commit ccb63ed78dfba6b67a696fe28c36d325e48cfdb3
Author: Tung Du <dinhtungdu@gmail.com>
Date: Tue Oct 6 16:14:20 2026 +0700
Add discount amount and percentage options to sale badges (#69180)
diff --git a/docs/block-development/reference/block-references.md b/docs/block-development/reference/block-references.md
index ee3f2438804..669d7fb4bfa 100644
--- a/docs/block-development/reference/block-references.md
+++ b/docs/block-development/reference/block-references.md
@@ -1191,7 +1191,7 @@ Displays an on-sale badge if the product is on-sale.
- **Category:** woocommerce-product-elements
- **Ancestor:** woocommerce/single-product, woocommerce/product-template, core/post-template, woocommerce/product-gallery
- **Supports:** align, color (background, gradients, text, ~~link~~), email, interactivity (clientNavigation), spacing (margin), typography (fontSize, lineHeight), ~~html~~
-- **Attributes:** productId
+- **Attributes:** badgeContent, prefix, productId, saleText, suffix
## Product SKU - woocommerce/product-sku
diff --git a/plugins/woocommerce/changelog/wooplug-7155-sale-badge-percent-off b/plugins/woocommerce/changelog/wooplug-7155-sale-badge-percent-off
new file mode 100644
index 00000000000..e7cb0e4af74
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7155-sale-badge-percent-off
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Show discount amounts or percentages on product sale badges, including the largest saving for variable products.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json
index dd5951359ee..56c8ea77112 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json
@@ -4,7 +4,11 @@
"description": "Displays an on-sale badge if the product is on-sale.",
"category": "woocommerce-product-elements",
"attributes": {
- "productId": { "type": "number", "default": 0 }
+ "productId": { "type": "number", "default": 0 },
+ "badgeContent": { "type": "string" },
+ "saleText": { "type": "string" },
+ "prefix": { "type": "string" },
+ "suffix": { "type": "string" }
},
"supports": {
"interactivity": {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
index d95739c1fbf..76e47991ab2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
@@ -1,7 +1,11 @@
/**
* External dependencies
*/
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ formatPrice,
+ getCurrencyFromPriceResponse,
+} from '@woocommerce/price-format';
import clsx from 'clsx';
import { Label } from '@woocommerce/blocks-components';
import {
@@ -10,7 +14,7 @@ import {
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
-import type { HTMLAttributes } from 'react';
+import type { HTMLAttributes, ReactElement } from 'react';
/**
* Internal dependencies
@@ -24,7 +28,7 @@ type Props = BlockAttributes &
isDescendentOfSingleProductTemplate: boolean;
};
-export const Block = ( props: Props ): JSX.Element | null => {
+export const Block = ( props: Props ): ReactElement | null => {
const { className, align, isDescendentOfSingleProductTemplate } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
@@ -41,6 +45,45 @@ export const Block = ( props: Props ): JSX.Element | null => {
return null;
}
+ const isNumeric =
+ props.badgeContent === 'amount' || props.badgeContent === 'percentage';
+
+ let label = props.saleText || __( 'Sale', 'woocommerce' );
+ if ( isNumeric && product.type !== 'grouped' ) {
+ const prices = 'prices' in product ? product.prices : undefined;
+ const regular = Number( prices?.regular_price );
+ const price = Number( prices?.price );
+ if ( regular > 0 && price < regular ) {
+ const discount = regular - price;
+ const percentage = Math.round( ( discount / regular ) * 100 );
+ if ( props.badgeContent === 'percentage' && percentage === 0 ) {
+ return null;
+ }
+ const value =
+ props.badgeContent === 'percentage'
+ ? sprintf(
+ /* translators: %s: discount percentage. %% is the percent sign. */
+ __( '%s%%', 'woocommerce' ),
+ percentage
+ )
+ : formatPrice(
+ discount,
+ getCurrencyFromPriceResponse( prices )
+ );
+ // Parent prices are independent minima, so the editor preview may differ from the largest variation discount.
+ label =
+ product.type === 'variable'
+ ? sprintf(
+ /* translators: %s: approximate discount for a variable product in the editor. */
+ __( 'Up to %s', 'woocommerce' ),
+ value
+ )
+ : `${ props.prefix ?? '' }${ value }${
+ props.suffix ?? ''
+ }`;
+ }
+ }
+
const alignClass =
typeof align === 'string'
? `wc-block-components-product-sale-badge--align-${ align }`
@@ -60,8 +103,12 @@ export const Block = ( props: Props ): JSX.Element | null => {
style={ styleProps.style }
>
<Label
- label={ __( 'Sale', 'woocommerce' ) }
- screenReaderLabel={ __( 'Product on sale', 'woocommerce' ) }
+ label={ label }
+ screenReaderLabel={ sprintf(
+ /* translators: %s: sale badge text. */
+ __( 'Product on sale: %s', 'woocommerce' ),
+ label
+ ) }
/>
</div>
);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx
index c5239b6d537..c36a0d0c685 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx
@@ -1,8 +1,11 @@
/**
* External dependencies
*/
-import { useBlockProps } from '@wordpress/block-editor';
+import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
+import { PanelBody, SelectControl, TextControl } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
import type { BlockEditProps } from '@wordpress/blocks';
+import type { ReactElement } from 'react';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
@@ -14,8 +17,9 @@ import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descend
const Edit = ( {
attributes,
+ setAttributes,
context,
-}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
+}: BlockEditProps< BlockAttributes > & { context: Context } ): ReactElement => {
const blockProps = useBlockProps();
// Remove the `style` prop from the block props to avoid passing it to the wrapper div.
@@ -29,14 +33,93 @@ const Edit = ( {
};
return (
- <div { ...wrapperProps }>
- <Block
- { ...blockAttrs }
- isDescendentOfSingleProductTemplate={
- isDescendentOfSingleProductTemplate
- }
- />
- </div>
+ <>
+ <InspectorControls>
+ <PanelBody title={ __( 'Badge content', 'woocommerce' ) }>
+ <SelectControl
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ label={ __( 'Show', 'woocommerce' ) }
+ value={ attributes.badgeContent || 'text' }
+ onChange={ ( badgeContent ) => {
+ if (
+ badgeContent === 'text' ||
+ badgeContent === 'amount' ||
+ badgeContent === 'percentage'
+ ) {
+ setAttributes( { badgeContent } );
+ }
+ } }
+ options={ [
+ {
+ label: __( 'Text', 'woocommerce' ),
+ value: 'text',
+ },
+ {
+ label: __( 'Amount', 'woocommerce' ),
+ value: 'amount',
+ },
+ {
+ label: __( 'Percentage', 'woocommerce' ),
+ value: 'percentage',
+ },
+ ] }
+ />
+ { ! attributes.badgeContent ||
+ attributes.badgeContent === 'text' ? (
+ <TextControl
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ label={ __( 'Text', 'woocommerce' ) }
+ help={ sprintf(
+ /* translators: %s: default sale badge text. */
+ __(
+ 'If left empty, the badge will display ‘%s’.',
+ 'woocommerce'
+ ),
+ __( 'Sale', 'woocommerce' )
+ ) }
+ value={
+ attributes.saleText ??
+ __( 'Sale', 'woocommerce' )
+ }
+ onChange={ ( saleText ) =>
+ setAttributes( { saleText } )
+ }
+ />
+ ) : (
+ <>
+ <TextControl
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ label={ __( 'Prefix', 'woocommerce' ) }
+ value={ attributes.prefix ?? '' }
+ onChange={ ( prefix ) =>
+ setAttributes( { prefix } )
+ }
+ />
+ <TextControl
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ label={ __( 'Suffix', 'woocommerce' ) }
+ value={ attributes.suffix ?? '' }
+ onChange={ ( suffix ) =>
+ setAttributes( { suffix } )
+ }
+ />
+ </>
+ ) }
+ </PanelBody>
+ </InspectorControls>
+ <div { ...wrapperProps }>
+ <Block
+ { ...blockAttrs }
+ isDescendentOfSingleProductTemplate={
+ isDescendentOfSingleProductTemplate
+ }
+ />
+ </div>
+ </>
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/test/block.test.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/test/block.test.tsx
new file mode 100644
index 00000000000..a5a41de6060
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/test/block.test.tsx
@@ -0,0 +1,181 @@
+import { render, screen } from '@testing-library/react';
+import { getLocaleData, resetLocaleData, setLocaleData } from '@wordpress/i18n';
+
+import { Block } from '../block';
+
+const product = {
+ id: 1,
+ on_sale: true,
+ type: 'variable',
+ prices: {
+ regular_price: '1000',
+ price: '900',
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_minor_unit: 2,
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ currency_prefix: '$',
+ currency_suffix: '',
+ },
+};
+
+jest.mock( '@woocommerce/shared-context', () => ( {
+ useInnerBlockLayoutContext: () => ( { parentClassName: '' } ),
+ useProductDataContext: () => ( { product } ),
+} ) );
+jest.mock( '@woocommerce/shared-hocs', () => ( {
+ withProductDataContext: ( Component: unknown ) => Component,
+} ) );
+jest.mock( '@woocommerce/base-hooks', () => ( {
+ useStyleProps: () => ( { className: '', style: {} } ),
+} ) );
+
+beforeEach( () => {
+ product.type = 'variable';
+ product.prices.regular_price = '1000';
+ product.prices.price = '900';
+} );
+
+it.each( [
+ [ '', 'Sale' ],
+ [ '0', '0' ],
+] )(
+ 'uses the text fallback only for empty custom text (%s)',
+ ( saleText, expected ) => {
+ render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="text"
+ saleText={ saleText }
+ />
+ );
+ expect( screen.getByText( expected ) ).toBeInTheDocument();
+ }
+);
+
+it.each< [ string, string, string | null ] >( [
+ [ 'simple', '99600', null ],
+ [ 'simple', '99500', '1%' ],
+] )(
+ 'rounds the %s percentage preview at price %s',
+ ( type, price, expected ) => {
+ product.type = type;
+ product.prices.regular_price = '100000';
+ product.prices.price = price;
+ const { container } = render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="percentage"
+ />
+ );
+ expect( container.textContent ).toBe(
+ expected === null
+ ? ''
+ : `${ expected }Product on sale: ${ expected }`
+ );
+ }
+);
+
+it( 'keeps amount badges for discounts whose percentage rounds to zero', () => {
+ product.prices.regular_price = '100000';
+ product.prices.price = '99600';
+ render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="amount"
+ />
+ );
+ expect( screen.getByText( 'Up to $4.00' ) ).toBeInTheDocument();
+} );
+
+it( 'previews a variable discount using parent prices without rendering on the server', () => {
+ product.prices.price = '900';
+ const { rerender } = render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="percentage"
+ />
+ );
+ expect( screen.getByText( 'Up to 10%' ) ).toBeInTheDocument();
+
+ product.prices.price = '1000';
+ rerender(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="percentage"
+ />
+ );
+ expect( screen.getByText( 'Sale' ) ).toBeInTheDocument();
+} );
+
+it( 'preserves currency units without custom affixes when using Up to', () => {
+ product.prices.price = '900';
+ render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="amount"
+ prefix="Save "
+ suffix=" off"
+ />
+ );
+ expect( screen.getByText( 'Up to $1.00' ) ).toBeInTheDocument();
+} );
+
+it.each( [
+ [ 'simple', 'Save 10% off' ],
+ [ 'variable', 'Up to 10%' ],
+] )(
+ 'formats the %s percentage label with custom affixes',
+ ( type, expected ) => {
+ product.type = type;
+ render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="percentage"
+ prefix="Save "
+ suffix=" off"
+ />
+ );
+ expect( screen.getByText( expected ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( `Product on sale: ${ expected }` )
+ ).toBeInTheDocument();
+ }
+);
+
+it( 'uses localized spacing before the percent sign', () => {
+ const localeData = getLocaleData( 'woocommerce' );
+ setLocaleData( { '%s%%': [ '%s\u00a0%%' ] }, 'woocommerce' );
+ try {
+ render(
+ <Block
+ productId={ 1 }
+ align={ false }
+ isDescendentOfSingleProductTemplate={ false }
+ badgeContent="percentage"
+ />
+ );
+ expect(
+ screen.getByText( 'Up to 10\u00a0%', {
+ normalizer: ( text ) => text,
+ } )
+ ).toBeInTheDocument();
+ } finally {
+ resetLocaleData( localeData, 'woocommerce' );
+ }
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts
index e64df6f3d4f..e9c5b1c9834 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts
@@ -1,3 +1,7 @@
export interface BlockAttributes {
productId: number;
+ badgeContent?: 'text' | 'amount' | 'percentage';
+ saleText?: string;
+ prefix?: string;
+ suffix?: string;
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
index cee9a9c242f..ddc88d90710 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -67,6 +67,60 @@ class ProductSaleBadge extends AbstractBlock {
$align = isset( $attributes['align'] ) ? $attributes['align'] : '';
+ $sale_text = $attributes['saleText'] ?? '';
+ $sale_text = '' === $sale_text ? __( 'Sale', 'woocommerce' ) : $sale_text;
+ $badge_content = $attributes['badgeContent'] ?? 'text';
+
+ if ( in_array( $badge_content, array( 'amount', 'percentage' ), true ) ) {
+ $amount = 0;
+ $percentage = 0;
+ $has_different_discounts = false;
+
+ if ( $product instanceof \WC_Product_Variable ) {
+ $prices = $product->get_variation_prices( 'amount' === $badge_content );
+ $discounts = array();
+ foreach ( $prices['price'] as $variation_id => $price ) {
+ $regular = (float) $prices['regular_price'][ $variation_id ];
+ $discount = max( 0, $regular - (float) $price );
+ $discounts[] = 'amount' === $badge_content ? $discount : ( $regular > 0 ? $discount / $regular : 0 );
+ if ( $regular <= 0 || (float) $price >= $regular ) {
+ continue;
+ }
+ $amount = max( $amount, $regular - (float) $price );
+ $percentage = max( $percentage, ( $regular - (float) $price ) / $regular );
+ }
+ $has_different_discounts = count( array_unique( $discounts ) ) > 1;
+ } else {
+ $regular = (float) $product->get_regular_price();
+ $price = (float) $product->get_price();
+ if ( 'amount' === $badge_content ) {
+ $regular = wc_get_price_to_display( $product, array( 'price' => $regular ) );
+ $price = wc_get_price_to_display( $product, array( 'price' => $price ) );
+ }
+ if ( $regular > 0 && $price < $regular ) {
+ $amount = $regular - $price;
+ $percentage = $amount / $regular;
+ }
+ }
+
+ if ( $amount > 0 ) {
+ if ( 'percentage' === $badge_content && round( $percentage * 100 ) < 1 ) {
+ return '';
+ }
+ $value = 'percentage' === $badge_content
+ /* translators: %s: discount percentage. %% is the percent sign. */
+ ? sprintf( __( '%s%%', 'woocommerce' ), round( $percentage * 100 ) )
+ : html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+
+ if ( $has_different_discounts ) {
+ /* translators: %s: largest discount across variations. */
+ $sale_text = sprintf( __( 'Up to %s', 'woocommerce' ), $value );
+ } else {
+ $sale_text = ( $attributes['prefix'] ?? '' ) . $value . ( $attributes['suffix'] ?? '' );
+ }
+ }
+ }
+
/**
* Filters the product sale badge text.
*
@@ -77,14 +131,15 @@ class ProductSaleBadge extends AbstractBlock {
* @param WC_Product $product The product object.
* @return string The filtered sale badge text.
*/
- $sale_text = apply_filters( 'woocommerce_sale_badge_text', __( 'Sale', 'woocommerce' ), $product );
+ $sale_text = apply_filters( 'woocommerce_sale_badge_text', $sale_text, $product );
$output = '<div class="wp-block-woocommerce-product-sale-badge ' . esc_attr( $classname ) . '">';
$output .= sprintf( '<div class="wc-block-components-product-sale-badge %1$s wc-block-components-product-sale-badge--align-%2$s" style="%3$s">', esc_attr( $classes_and_styles['classes'] ), esc_attr( $align ), esc_attr( $classes_and_styles['styles'] ) );
$output .= '<span class="wc-block-components-product-sale-badge__text" aria-hidden="true">' . esc_html( $sale_text ) . '</span>';
- $output .= '<span class="screen-reader-text">'
- . __( 'Product on sale', 'woocommerce' )
- . '</span>';
+ /* translators: %s: sale badge text. */
+ $screen_reader_text = sprintf( __( 'Product on sale: %s', 'woocommerce' ), $sale_text );
+
+ $output .= '<span class="screen-reader-text">' . esc_html( $screen_reader_text ) . '</span>';
$output .= '</div></div>';
return $output;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
index b4c58ab1276..988332dc616 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -85,6 +85,215 @@ class ProductSaleBadge extends \WP_UnitTestCase {
}
}
+ /**
+ * @testdox Product Sale Badge displays the configured content for a simple sale product.
+ * @dataProvider provider_product_sale_badge_content
+ * @param array $attributes Badge attributes.
+ * @param string $expected Expected badge text.
+ */
+ public function test_product_sale_badge_content( array $attributes, string $expected ): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( '20' );
+ $product->set_sale_price( '15' );
+ $product->save();
+
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( (object) $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+
+ $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+ $this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+ }
+
+ /**
+ * Sale badge content options.
+ *
+ * @return array<string, array{array, string}>
+ */
+ public function provider_product_sale_badge_content(): array {
+ return array(
+ 'empty' => array( array( 'saleText' => '' ), 'Sale' ),
+ 'zero text' => array( array( 'saleText' => '0' ), '0' ),
+ 'custom' => array(
+ array( 'saleText' => 'Special offer' ),
+ 'Special offer',
+ ),
+ 'percentage' => array(
+ array(
+ 'badgeContent' => 'percentage',
+ 'prefix' => 'Save ',
+ 'suffix' => ' off',
+ ),
+ 'Save 25% off',
+ ),
+ 'amount' => array(
+ array(
+ 'badgeContent' => 'amount',
+ 'prefix' => 'Save ',
+ ),
+ 'Save $5.00',
+ ),
+ );
+ }
+
+ /**
+ * @testdox Percentage badges omit zero-rounded discounts while preserving the rounding boundary.
+ * @dataProvider provider_percentage_badge_rounding
+ * @param string $type Product type.
+ * @param string $sale Sale price.
+ * @param string $mode Badge content mode.
+ * @param string $expected Expected badge text, or empty for an omitted badge.
+ */
+ public function test_percentage_badge_rounding( string $type, string $sale, string $mode, string $expected ): void {
+ $product = 'variable' === $type ? \WC_Helper_Product::create_variation_product() : \WC_Helper_Product::create_simple_product();
+ $priced = 'variable' === $type ? wc_get_product( $product->get_children()[0] ) : $product;
+ $priced->set_regular_price( '1000' );
+ $priced->set_sale_price( $sale );
+ $priced->save();
+
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"' . $mode . '"} /--><!-- /wp:woocommerce/single-product -->' );
+
+ if ( '' === $expected ) {
+ $this->assertStringNotContainsString( 'wc-block-components-product-sale-badge', $markup, 'Zero-rounded percentage badges should be omitted.' );
+ } else {
+ $this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
+ }
+ }
+
+ /**
+ * Percentage rounding and unchanged amount-mode cases.
+ *
+ * @return array
+ */
+ public function provider_percentage_badge_rounding(): array {
+ return array(
+ 'simple zero' => array( 'simple', '996', 'percentage', '' ),
+ 'simple half' => array( 'simple', '995', 'percentage', '1%' ),
+ 'variable zero' => array( 'variable', '996', 'percentage', '' ),
+ 'variable half' => array( 'variable', '995', 'percentage', 'Up to 1%' ),
+ 'simple amount' => array( 'simple', '996', 'amount', '$4.00' ),
+ 'variable amount' => array( 'variable', '996', 'amount', 'Up to $4.00' ),
+ );
+ }
+
+ /**
+ * @testdox Variable product badge uses each variation's own regular price for the largest discount.
+ */
+ public function test_variable_product_sale_badge_displays_maximum_discount(): void {
+ $product = \WC_Helper_Product::create_variation_product();
+ $variations = $product->get_children();
+
+ $first = wc_get_product( $variations[0] );
+ $first->set_sale_price( '5' );
+ $first->save();
+
+ $second = wc_get_product( $variations[1] );
+ $second->set_sale_price( '9' );
+ $second->save();
+
+ foreach (
+ array(
+ 'percentage' => 'Up to 50%',
+ 'amount' => 'Up to $6.00',
+ ) as $mode => $expected
+ ) {
+ $attributes = array(
+ 'badgeContent' => $mode,
+ 'prefix' => 'Save ',
+ 'suffix' => ' off',
+ );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+ $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+ $this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+ }
+ }
+
+ /**
+ * @testdox Uniform variation discounts retain affixes only in the matching content mode.
+ * @dataProvider provider_uniform_variation_discounts
+ * @param string $sale Second variation sale price.
+ * @param string $mode Badge content mode.
+ * @param string $expected Expected badge text.
+ */
+ public function test_uniform_variation_discounts( string $sale, string $mode, string $expected ): void {
+ $product = \WC_Helper_Product::create_variation_product();
+ foreach ( $product->get_children() as $index => $id ) {
+ $variation = wc_get_product( $id );
+ $variation->set_regular_price( 0 === $index ? '10' : '20' );
+ $variation->set_sale_price( 0 === $index ? '9' : $sale );
+ $variation->save();
+ }
+ $attributes = array(
+ 'badgeContent' => $mode,
+ 'prefix' => 'Save ',
+ 'suffix' => ' off',
+ );
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+ $this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
+ $this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+ }
+
+ /**
+ * Uniform percentages, uniform amounts, and undiscounted variations.
+ *
+ * @return array
+ */
+ public function provider_uniform_variation_discounts(): array {
+ return array(
+ 'equal percentage' => array( '18', 'percentage', 'Save 10% off' ),
+ 'different amount' => array( '18', 'amount', 'Up to $2.00' ),
+ 'equal amount' => array( '19', 'amount', 'Save $1.00 off' ),
+ 'different percentage' => array( '19', 'percentage', 'Up to 10%' ),
+ 'undiscounted variation' => array( '20', 'percentage', 'Up to 10%' ),
+ );
+ }
+
+ /**
+ * @testdox Discount amount reflects prices shown with tax on the storefront.
+ */
+ public function test_discount_amount_includes_displayed_tax(): void {
+ add_filter( 'wc_tax_enabled', '__return_true' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_tax_display_shop', 'incl' );
+ $tax_id = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '20.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => 1,
+ 'tax_rate_compound' => 0,
+ 'tax_rate_shipping' => 1,
+ 'tax_rate_order' => 1,
+ 'tax_rate_class' => '',
+ )
+ );
+
+ try {
+ $simple = \WC_Helper_Product::create_simple_product();
+ $simple->set_regular_price( '100' );
+ $simple->set_sale_price( '80' );
+ $simple->save();
+
+ $variable = \WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $variable->get_children()[0] );
+ $variation->set_regular_price( '100' );
+ $variation->set_sale_price( '80' );
+ $variation->save();
+
+ foreach (
+ array(
+ $simple->get_id() => '$24.00',
+ $variable->get_id() => 'Up to $24.00',
+ ) as $product_id => $expected
+ ) {
+ $markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"amount"} /--><!-- /wp:woocommerce/single-product -->' );
+ $this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+ }
+ } finally {
+ \WC_Tax::_delete_tax_rate( $tax_id );
+ remove_filter( 'wc_tax_enabled', '__return_true' );
+ }
+ }
+
/**
* Alignment values for the Sale Badge.
*