Commit 695243f098d for woocommerce
commit 695243f098d859f81d673e57e21ab588df904e2f
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date: Thu Nov 5 11:50:26 2026 +0100
Add sale dates to product schema offers (#70586)
* Add sale dates to product schema offers
* Add changelog entry for product sale dates
* Fix sale end dates for the store timezone
* Fix sale date test on PHP 7.4
* Fix sale date tests on 32-bit PHP
* Fix localized sale dates in product schema
diff --git a/plugins/woocommerce/changelog/add-product-schema-sale-dates b/plugins/woocommerce/changelog/add-product-schema-sale-dates
new file mode 100644
index 00000000000..05106f8696b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-schema-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Include active sale dates in product structured data so shopping agents can see when sale prices apply.
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index 713dbb94942..fe064584c90 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -460,7 +460,8 @@ class WC_Structured_Data {
? wc_get_price_including_tax( $offer_source, array( 'price' => $offer_source->get_sale_price() ) )
: wc_get_price_excluding_tax( $offer_source, array( 'price' => $offer_source->get_sale_price() ) );
if ( $offer_source->get_date_on_sale_to() ) {
- $sale_price_valid_until = gmdate( 'Y-m-d', $offer_source->get_date_on_sale_to()->getTimestamp() );
+ $sale_date_to = clone $offer_source->get_date_on_sale_to();
+ $sale_price_valid_until = (string) $sale_date_to->setTimezone( wp_timezone() )->format( 'Y-m-d' );
}
// We add the sale price to the top of the array so it's the first offer.
@@ -506,6 +507,17 @@ class WC_Structured_Data {
$markup_offer['priceCurrency'] = $currency;
}
+ if ( ( ! $product->is_type( array( ProductType::VARIABLE, ProductType::GROUPED ) ) || $selected_variation ) && $offer_source->is_on_sale() ) {
+ $date_on_sale_from = $offer_source->get_date_on_sale_from();
+ $date_on_sale_to = $offer_source->get_date_on_sale_to();
+ if ( $date_on_sale_from ) {
+ $markup_offer['validFrom'] = ( clone $date_on_sale_from )->setTimezone( wp_timezone() )->format( DATE_ATOM );
+ }
+ if ( $date_on_sale_to ) {
+ $markup_offer['validThrough'] = ( clone $date_on_sale_to )->setTimezone( wp_timezone() )->format( DATE_ATOM );
+ }
+ }
+
$markup['offers'] = array( apply_filters( 'woocommerce_structured_data_product_offer', $markup_offer, $product ) );
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index adce45e8099..e3f79598c75 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -684,15 +684,18 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
}
/**
- * Test on-sale simple product offer reports the sale price at the offer top level.
+ * @testdox An active sale offer includes the sale price and dates with daylight saving offsets.
*
* @return void
*/
public function test_simple_product_offer_on_sale_uses_sale_price_at_offer_level(): void {
+ update_option( 'timezone_string', 'America/New_York' );
$product = WC_Helper_Product::create_simple_product();
$product->set_regular_price( '100' );
$product->set_sale_price( '70' );
$product->set_price( '70' );
+ $product->set_date_on_sale_from( strtotime( '2020-01-01 12:00:00 UTC' ) );
+ $product->set_date_on_sale_to( strtotime( '2037-06-01 12:00:00 UTC' ) );
$product->save();
$this->structured_data->generate_product_data( $product );
@@ -704,6 +707,40 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
$this->assertEquals( '70.00', $offer['price'] );
$this->assertEquals( '70.00', $offer['priceSpecification'][0]['price'] );
$this->assertEquals( get_woocommerce_currency(), $offer['priceCurrency'] );
+ $this->assertSame( '2020-01-01T07:00:00-05:00', $offer['validFrom'] );
+ $this->assertSame( '2037-06-01T08:00:00-04:00', $offer['validThrough'] );
+ }
+
+ /**
+ * @testdox Sale dates are emitted only when configured and the sale is active.
+ * @testWith ["active"]
+ * ["future"]
+ * ["expired"]
+ * ["unscheduled"]
+ *
+ * @param string $schedule Sale schedule to check.
+ */
+ public function test_sale_offer_dates_with_manual_offset( string $schedule ): void {
+ update_option( 'timezone_string', '' );
+ update_option( 'gmt_offset', 5.5 );
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( '100' );
+ $product->set_sale_price( '70' );
+ $product->set_price( '70' );
+ if ( 'unscheduled' !== $schedule ) {
+ $product->set_date_on_sale_from( strtotime( 'future' === $schedule ? '+1 day' : '2020-01-01 12:00:00 UTC' ) );
+ $product->set_date_on_sale_to( strtotime( 'expired' === $schedule ? '-1 day' : '2037-06-01 12:00:00 UTC' ) );
+ }
+
+ $this->structured_data->generate_product_data( $product );
+ $offer = $this->structured_data->get_data()[0]['offers'][0];
+ if ( 'active' === $schedule ) {
+ $this->assertSame( '2020-01-01T17:30:00+05:30', $offer['validFrom'] );
+ $this->assertSame( '2037-06-01T17:30:00+05:30', $offer['validThrough'] );
+ } else {
+ $this->assertArrayNotHasKey( 'validFrom', $offer );
+ $this->assertArrayNotHasKey( 'validThrough', $offer );
+ }
}
/**
@@ -902,7 +939,18 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
* @return void
*/
public function test_variable_product_with_selected_variation_uses_single_offer(): void {
+ update_option( 'timezone_string', 'UTC' );
$product = WC_Helper_Product::create_variation_product();
+ foreach ( $product->get_children() as $variation_id ) {
+ $variation = wc_get_product( $variation_id );
+ if ( 'DUMMY SKU VARIABLE HUGE RED 0' === $variation->get_sku() ) {
+ $variation->set_sale_price( '12' );
+ $variation->set_date_on_sale_from( strtotime( '2020-01-01 12:00:00 UTC' ) );
+ $variation->set_date_on_sale_to( strtotime( '2037-06-01 12:00:00 UTC' ) );
+ $variation->save();
+ break;
+ }
+ }
WC_Product_Variable::sync( $product->get_id() );
$product = wc_get_product( $product->get_id() );
@@ -917,7 +965,9 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
$offer = $data[0]['offers'][0];
$this->assertEquals( 'Offer', $offer['@type'] );
- $this->assertEquals( '16.00', $offer['price'] );
+ $this->assertEquals( '12.00', $offer['price'] );
+ $this->assertSame( '2020-01-01T12:00:00+00:00', $offer['validFrom'] );
+ $this->assertSame( '2037-06-01T12:00:00+00:00', $offer['validThrough'] );
$this->assertArrayNotHasKey( 'lowPrice', $offer );
$this->assertArrayNotHasKey( 'highPrice', $offer );
$this->assertEquals( get_woocommerce_currency(), $offer['priceCurrency'] );
@@ -967,7 +1017,12 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
* @return void
*/
public function test_variable_product_without_full_selection_uses_aggregate_offer(): void {
- $product = WC_Helper_Product::create_variation_product();
+ $product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $product->get_children()[0] );
+ $variation->set_sale_price( '8' );
+ $variation->set_date_on_sale_from( strtotime( '-1 day' ) );
+ $variation->set_date_on_sale_to( strtotime( '+1 day' ) );
+ $variation->save();
WC_Product_Variable::sync( $product->get_id() );
$product = wc_get_product( $product->get_id() );
@@ -982,6 +1037,8 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
$this->assertEquals( 'AggregateOffer', $offer['@type'] );
$this->assertArrayHasKey( 'lowPrice', $offer );
$this->assertArrayHasKey( 'highPrice', $offer );
+ $this->assertArrayNotHasKey( 'validFrom', $offer );
+ $this->assertArrayNotHasKey( 'validThrough', $offer );
$this->assertArrayNotHasKey( 'inProductGroupWithID', $data[0] );
} finally {
unset( $_GET['attribute_pa_size'] );