Commit a5b92306a20 for woocommerce

commit a5b92306a201ea01350b786800dc1de07585dde2
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date:   Thu Nov 5 08:31:59 2026 +0100

    Show the store return policy on product pages (#70619)

    * Show the store return policy on product pages

    * Add changelog entry for return policy schema

diff --git a/plugins/woocommerce/changelog/add-product-return-policy-schema b/plugins/woocommerce/changelog/add-product-return-policy-schema
new file mode 100644
index 00000000000..7c0ac31eb1a
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-return-policy-schema
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Make the store return policy available to agents on product pages.
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index 4cc02958fe2..713dbb94942 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -158,7 +158,7 @@ class WC_Structured_Data {
 		$types   = array();
 		$types[] = is_shop() || is_product_category() || is_product() ? 'product' : '';
 		$types[] = is_shop() && is_front_page() ? 'website' : '';
-		$types[] = $policy_page_id > 0 && is_page( $policy_page_id ) && ! doing_action( 'woocommerce_email_order_details' ) ? 'onlinestore' : '';
+		$types[] = $policy_page_id > 0 && ( is_page( $policy_page_id ) || is_product() ) && ! doing_action( 'woocommerce_email_order_details' ) ? 'onlinestore' : '';
 		$types[] = is_product() ? 'review' : '';
 		$types[] = 'breadcrumblist';
 		$types[] = 'order';
@@ -706,7 +706,7 @@ class WC_Structured_Data {
 	}

 	/**
-	 * Generates link-only return-policy data on the selected public policy page.
+	 * Generates link-only return-policy data on product pages and the selected public policy page.
 	 *
 	 * Hooked into `wp_footer` before structured data is output, so it cannot leak into order emails.
 	 *
@@ -714,7 +714,7 @@ class WC_Structured_Data {
 	 */
 	public function generate_online_store_data(): void {
 		$page_id = wc_get_page_id( 'refund_returns' );
-		if ( $page_id <= 0 || ! is_page( $page_id ) ) {
+		if ( $page_id <= 0 || ( ! is_page( $page_id ) && ! is_product() ) ) {
 			return;
 		}

@@ -739,7 +739,7 @@ class WC_Structured_Data {
 		);

 		/**
-		 * Filters the store and return-policy structured data on the policy page.
+		 * Filters the store and return-policy structured data.
 		 *
 		 * @since 11.3.0
 		 * @param array   $markup Structured data for the online store.
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 3a9160e68d0..adce45e8099 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
@@ -1103,6 +1103,92 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 		$this->assertStringNotContainsString( 'OnlineStore', $email, 'The policy node must not appear in an order email, even after the footer.' );
 	}

+	/**
+	 * @testdox Product pages output the store return-policy link only while the selected page is public.
+	 */
+	public function test_product_page_outputs_online_store_return_policy(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$page_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_refund_returns_page_id', $page_id );
+		$this->go_to( get_permalink( $product->get_id() ) );
+
+		$filtered_page = null;
+		add_filter(
+			'woocommerce_structured_data_online_store',
+			static function ( $markup, $page ) use ( &$filtered_page ) {
+				$filtered_page  = $page;
+				$markup['name'] = 'Filtered store';
+				return $markup;
+			},
+			10,
+			2
+		);
+		$this->structured_data->generate_product_data( $product );
+		$this->structured_data->generate_online_store_data();
+		ob_start();
+		$this->structured_data->output_structured_data();
+		$output = ob_get_clean();
+		$this->assertStringContainsString( '"@type":"Product"', $output );
+		$this->assertStringContainsString( '"@type":"OnlineStore"', $output );
+		$this->assertStringContainsString( '"merchantReturnLink":"' . get_permalink( $page_id ) . '"', $output );
+		$this->assertStringContainsString( '"name":"Filtered store"', $output );
+		$this->assertSame( $page_id, $filtered_page->ID );
+
+		remove_all_actions( 'woocommerce_email_order_details' );
+		add_action( 'woocommerce_email_order_details', array( $this->structured_data, 'output_email_structured_data' ), 30, 3 );
+		ob_start();
+		do_action( 'woocommerce_email_order_details', null, false, false );
+		$this->assertStringNotContainsString( 'OnlineStore', ob_get_clean(), 'Order emails must not include the store node.' );
+
+		foreach ( array( 'draft', 'private', 'trash' ) as $status ) {
+			wp_update_post(
+				array(
+					'ID'          => $page_id,
+					'post_status' => $status,
+				)
+			);
+			$structured_data = new WC_Structured_Data();
+			$structured_data->generate_online_store_data();
+			$this->assertSame( array(), $structured_data->get_data(), "{$status} pages must not be advertised." );
+		}
+
+		wp_update_post(
+			array(
+				'ID'            => $page_id,
+				'post_status'   => 'publish',
+				'post_password' => 'secret',
+			)
+		);
+		$structured_data = new WC_Structured_Data();
+		$structured_data->generate_online_store_data();
+		$this->assertSame( array(), $structured_data->get_data(), 'Password-protected pages must not be advertised.' );
+
+		wp_update_post(
+			array(
+				'ID'            => $page_id,
+				'post_password' => '',
+			)
+		);
+		$invalid_url = static function () {
+			return 'javascript:alert(1)';
+		};
+		add_filter( 'woocommerce_get_refund_returns_page_permalink', $invalid_url );
+		$structured_data = new WC_Structured_Data();
+		$structured_data->generate_online_store_data();
+		$this->assertSame( array(), $structured_data->get_data(), 'Invalid policy URLs must not be advertised.' );
+		remove_filter( 'woocommerce_get_refund_returns_page_permalink', $invalid_url );
+
+		wp_delete_post( $page_id, true );
+		$structured_data = new WC_Structured_Data();
+		$structured_data->generate_online_store_data();
+		$this->assertSame( array(), $structured_data->get_data(), 'Deleted pages must not be advertised.' );
+	}
+
 	/**
 	 * @testdox No return-policy data is generated on another page or for an unpublished, private, or password-protected page.
 	 */