Commit a5039415972 for woocommerce

commit a5039415972efccc78e4f6b37cd7794a8a9f763f
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Tue Jun 30 13:08:56 2026 +0200

    Add contextual Marketplace promo card to the WooCommerce Orders screen (#65970)

    * Add local rule-based marketplace promos

    * Document local promo rule schema contract

    * Add contextual Marketplace promo card to the Orders screen

    Render rule-based promo cards on the WooCommerce Orders list (HPOS and legacy)
    by reusing the marketplace PromoCard, resolving the eligible promo server-side
    via the existing rule engine and gating on a `pages` entry of `{ page: 'wc-orders' }`.
    Attach `surface` and `order_count` to the card's Tracks events.

    * Address review: memoize orders promo lookup, enqueue card styles

    - Resolve the Orders promo card once per request (it was evaluated twice: on
      admin_enqueue_scripts and again on the tablenav render).
    - Enqueue the card stylesheet and move the container layout to CSS instead of
      an inline style attribute.
    - Mark get_orders_promo_card() @internal and reset its request cache between tests.

    * Address review: fail closed on malformed nested rules

    - Recursively validate not/or operands and reject empty operands and unknown
      nested rule types, so a malformed payload cannot make `not` evaluate to true
      (a fail-open). Add tests for the empty-operand, unknown-type, and empty-or cases.
    - Remove only this test's option_active_plugins filter rather than all callbacks.
    - Add @since 11.0.0 to the new public hook callbacks.

    * Address review: accept RemoteSpecs OR AND-group operands

    - Validate each OR operand separately. RemoteSpecs allows an OR operand to be
      an AND group (array of rules); rules_are_valid() required each operand to be
      an object, so it wrongly rejected those valid payloads. Add a test for the
      AND-group operand shape.
    - Move @since 11.0.0 to the last docblock line per the WooCommerce guideline.


    * Address review: unique style handle to avoid Orders-screen collision

    WCAdminAssets::register_style() registers every style under the shared
    `wc-admin-style` handle, so the Orders list (which also enqueues Fulfillments
    styles the same way) would drop one stylesheet. Enqueue the card CSS under a
    unique `wc-admin-marketplace-orders-promo` handle instead.

    Also make both operands in the OR-AND-group test actual AND groups and fix the
    comment/indentation.

    * Render the Orders promo as a full-width banner above the table

    The extra_tablenav hook wedged the card into the bulk-actions toolbar. Instead,
    localize the resolved promo and have the wp-admin script insert it as a full-width
    banner before the orders list form (#wc-orders-filter / #posts-filter) — above the
    filters and table, on both HPOS and the legacy list. Reshape the marketplace
    PromoCard into a horizontal banner via the entry stylesheet. This drops the
    tablenav render hooks (no new Core hooks added).

    * Persist Orders promo dismissal server-side per promo id

    Add a stable `id` to the promo object and persist dismissals in the
    `_wc_marketplace_dismissed_promos` user meta via a new
    `POST wc-admin/marketplace-promotions/dismiss` REST route. The card
    resolver skips dismissed ids, so a dismissed promo is never enqueued
    again — permanent and consistent across every Orders URL and device,
    replacing the previous localStorage-per-URL behavior.

    The script dismisses with a plain fetch to a localized rest_url() +
    wp_rest nonce rather than apiFetch, whose root/nonce middleware is not
    configured on the classic (non-SPA) Orders page.

    * Fix PHPStan findings in marketplace promotions

    - Annotate the dismiss REST callback's WP_REST_Request param with its
      generic type (matching the codebase convention), with the Squiz
      IncorrectTypeHint sniff ignored on the signature as elsewhere in core.
    - Guard wp_json_encode()'s string|false return before json_decode() in
      promotion_rules_pass(); a failed encode now fails closed.

    * Keep core Tracks fields authoritative in PromoCard

    Spread eventProperties before the authoritative path/format/target_uri
    fields so a caller cannot accidentally override the event schema
    (addresses CodeRabbit review feedback).

    * Bypass localStorage visibility check when PromoCard has onDismiss

    When a caller provides onDismiss it owns dismissal persistence (the
    Orders card uses a server-side, per-user record), so PromoCard should
    not also consult the localStorage-by-path fallback for initial
    visibility — a stale local entry for the same URL could otherwise hide a
    card the server says to show. localStorage is now used only when no
    onDismiss callback is given (addresses review feedback).

diff --git a/plugins/woocommerce/changelog/add-orders-screen-marketplace-promo b/plugins/woocommerce/changelog/add-orders-screen-marketplace-promo
new file mode 100644
index 00000000000..3155b5cd249
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-orders-screen-marketplace-promo
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a contextual Marketplace recommendation card to the WooCommerce Orders screen, shown to eligible stores based on locally-evaluated promotion rules.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/promo-card/promo-card.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/promo-card/promo-card.tsx
index 56c478a1553..c3eb6b08dc7 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/promo-card/promo-card.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/promo-card/promo-card.tsx
@@ -16,6 +16,11 @@ import PercentSVG from './images/percent';

 interface PromoCardProps {
 	promotion: Promotion;
+	// Extra properties merged into the promotion Tracks events (e.g. order_count, surface).
+	eventProperties?: Record< string, unknown >;
+	// Called on dismiss. When provided, replaces the default localStorage dismissal so the
+	// caller can persist it elsewhere (e.g. server-side, per user).
+	onDismiss?: () => void;
 }

 const imageComponents = {
@@ -24,6 +29,8 @@ const imageComponents = {

 const PromoCard = ( {
 	promotion,
+	eventProperties = {},
+	onDismiss,
 }: PromoCardProps ): React.ReactElement | null => {
 	const path = window.location.pathname + window.location.search;

@@ -32,13 +39,18 @@ const PromoCard = ( {
 			localStorage.getItem( 'wc-marketplaceDismissedPromos' ) || '[]'
 		);

+	// When a caller provides onDismiss it owns dismissal persistence (e.g. server-side, per user),
+	// so the localStorage fallback is bypassed for both the initial visibility check here and the
+	// write in handleDismiss. Without a callback, fall back to the localStorage-by-path behavior.
 	const [ isVisible, setIsVisible ] = useState(
-		! getDismissedURIs().includes( path )
+		onDismiss ? true : ! getDismissedURIs().includes( path )
 	);

 	useEffect( () => {
 		if ( isVisible ) {
 			recordEvent( 'marketplace_promotion_viewed', {
+				// Custom properties first so the authoritative fields below win.
+				...eventProperties,
 				path,
 				format: 'promo-card',
 			} );
@@ -51,12 +63,18 @@ const PromoCard = ( {

 	const handleDismiss = () => {
 		setIsVisible( false );
-		localStorage.setItem(
-			'wc-marketplaceDismissedPromos',
-			JSON.stringify( getDismissedURIs().concat( path ) )
-		);
+
+		if ( onDismiss ) {
+			onDismiss();
+		} else {
+			localStorage.setItem(
+				'wc-marketplaceDismissedPromos',
+				JSON.stringify( getDismissedURIs().concat( path ) )
+			);
+		}

 		recordEvent( 'marketplace_promotion_dismissed', {
+			...eventProperties,
 			path,
 			format: 'promo-card',
 		} );
@@ -64,6 +82,7 @@ const PromoCard = ( {

 	const handleClick = () => {
 		recordEvent( 'marketplace_promotion_actioned', {
+			...eventProperties,
 			path,
 			target_uri: promotion.cta_link,
 			format: 'promo-card',
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/marketplace-orders-promo/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/marketplace-orders-promo/index.tsx
new file mode 100644
index 00000000000..0c10e79e4c4
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/marketplace-orders-promo/index.tsx
@@ -0,0 +1,87 @@
+/**
+ * Inserts a contextual Marketplace promo card on the WooCommerce Orders list.
+ *
+ * The Orders list is a classic (non-SPA) admin page. The orders list table is wrapped in a
+ * form, so the card is inserted as a full-width banner immediately before that form — above the
+ * filters and table. The promotion is rule-resolved server-side and localized as
+ * `window.wcOrdersPromo`; PromoCard handles impression/click/dismiss Tracks. Dismissal is
+ * persisted server-side (per user, by promo id) so the card stays hidden across URLs and devices.
+ */
+
+/**
+ * External dependencies
+ */
+import { createRoot } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import PromoCard from '~/marketplace/components/promo-card/promo-card';
+import { Promotion } from '~/marketplace/components/promotions/types';
+
+declare global {
+	interface Window {
+		wcOrdersPromo?: {
+			id?: string;
+			promotion: Promotion;
+			order_count?: number;
+			dismiss_url?: string;
+			dismiss_nonce?: string;
+		};
+	}
+}
+
+const data = window.wcOrdersPromo;
+
+// Insert the banner at the top of the list content, above the status filter links
+// (`.subsubsub`) and below any admin notices. Fall back to the list form
+// (`#wc-orders-filter` on HPOS, `#posts-filter` on the legacy list) if the status links
+// are not rendered (e.g. a single status view).
+const anchor =
+	document.querySelector( '.subsubsub' ) ||
+	document.getElementById( 'wc-orders-filter' ) ||
+	document.getElementById( 'posts-filter' );
+
+if ( data && data.promotion && anchor && anchor.parentNode ) {
+	const root = document.createElement( 'div' );
+	root.className = 'woocommerce-marketplace-orders-promo';
+	anchor.parentNode.insertBefore( root, anchor );
+
+	const promoId = data.id;
+
+	const eventProperties: Record< string, unknown > = { surface: 'orders' };
+	if ( typeof data.order_count === 'number' ) {
+		eventProperties.order_count = data.order_count;
+	}
+
+	const dismissUrl = data.dismiss_url;
+	const dismissNonce = data.dismiss_nonce;
+
+	// Persist the dismissal with a plain fetch to the localized REST URL + nonce. This avoids
+	// depending on apiFetch's root/nonce middleware, which is not configured on this classic
+	// (non-SPA) admin page. The card is already hidden client-side; a failed POST only means it
+	// may reappear on the next load.
+	const onDismiss =
+		promoId && dismissUrl && dismissNonce
+			? () => {
+					fetch( dismissUrl, {
+						method: 'POST',
+						credentials: 'same-origin',
+						headers: {
+							'Content-Type': 'application/json',
+							'X-WP-Nonce': dismissNonce,
+						},
+						body: JSON.stringify( { id: promoId } ),
+					} ).catch( () => {} );
+			  }
+			: undefined;
+
+	createRoot( root ).render(
+		<PromoCard
+			promotion={ data.promotion }
+			eventProperties={ eventProperties }
+			onDismiss={ onDismiss }
+		/>
+	);
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/marketplace-orders-promo/style.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/marketplace-orders-promo/style.scss
new file mode 100644
index 00000000000..d8081eea6ef
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/marketplace-orders-promo/style.scss
@@ -0,0 +1,52 @@
+// Full-width promo banner above the orders table. Reshapes the marketplace PromoCard
+// (whose default layout stacks vertically for the marketplace grid) into a horizontal banner.
+.woocommerce-marketplace-orders-promo {
+	clear: both;
+	margin: 16px 0;
+
+	.promo-card {
+		// Flat card chrome consistent with other order-screen banners.
+		box-shadow: none;
+		border: 1px solid #e0e0e0;
+		border-radius: 8px;
+
+		.promo-card__body {
+			display: flex;
+			flex-direction: row;
+			align-items: center;
+			justify-content: space-between;
+			gap: 24px;
+			padding: 16px 20px;
+		}
+
+		.promo-content-image {
+			flex: 1 1 auto;
+			align-items: center;
+		}
+
+		.promo-content {
+			gap: 4px;
+		}
+
+		.promo-title {
+			font-weight: 600;
+		}
+
+		.promo-links {
+			flex: 0 0 auto;
+			margin: 0;
+		}
+	}
+
+	@media (max-width: 782px) {
+		.promo-card .promo-card__body {
+			flex-direction: column;
+			align-items: flex-start;
+			gap: 12px;
+		}
+
+		.promo-card .promo-links {
+			align-self: flex-start;
+		}
+	}
+}
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-marketplace-promotions.php b/plugins/woocommerce/includes/admin/class-wc-admin-marketplace-promotions.php
index c2e8d9589be..e4ded33b219 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-marketplace-promotions.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-marketplace-promotions.php
@@ -6,6 +6,14 @@
  * @version  2.5.0
  */

+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\FailRuleProcessor;
+use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\GetRuleProcessor;
+use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\OrdersProvider;
+use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleEvaluator;
+use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
+
 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }
@@ -15,10 +23,12 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_Admin_Marketplace_Promotions {

-	const CRON_NAME           = 'woocommerce_marketplace_cron_fetch_promotions';
-	const TRANSIENT_NAME      = 'woocommerce_marketplace_promotions_v2';
-	const TRANSIENT_LIFE_SPAN = DAY_IN_SECONDS;
-	const PROMOTIONS_API_URL  = 'https://woocommerce.com/wp-json/wccom-extensions/3.0/promotions';
+	const CRON_NAME             = 'woocommerce_marketplace_cron_fetch_promotions';
+	const RULE_BASED_FORMAT     = 'rule-based-promo-card';
+	const TRANSIENT_NAME        = 'woocommerce_marketplace_promotions_v2';
+	const TRANSIENT_LIFE_SPAN   = DAY_IN_SECONDS;
+	const PROMOTIONS_API_URL    = 'https://woocommerce.com/wp-json/wccom-extensions/3.0/promotions';
+	const DISMISSED_PROMOS_META = '_wc_marketplace_dismissed_promos';

 	/**
 	 * The user's locale, for example en_US.
@@ -27,6 +37,20 @@ class WC_Admin_Marketplace_Promotions {
 	 */
 	public static string $locale;

+	/**
+	 * Request-scoped cache of the eligible Orders-screen promo card (or null).
+	 *
+	 * @var array|null
+	 */
+	private static $orders_promo_card = null;
+
+	/**
+	 * Whether the Orders-screen promo card has been resolved this request.
+	 *
+	 * @var bool
+	 */
+	private static $orders_promo_card_resolved = false;
+
 	/**
 	 * On all admin pages, try go get Marketplace promotions every day.
 	 * Shows notice and adds menu badge to WooCommerce Extensions item
@@ -51,10 +75,14 @@ class WC_Admin_Marketplace_Promotions {
 		// Fetch promotions from the API and store them in a transient.
 		add_action( self::CRON_NAME, array( __CLASS__, 'update_promotions' ) );

+		// Registered on every request so the promo dismissal endpoint is available; the
+		// rest_api_init action only fires during REST requests, and init (priority 11) runs first.
+		add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
+
 		if (
-			defined( 'DOING_AJAX' ) && DOING_AJAX
-			|| defined( 'DOING_CRON' ) && DOING_CRON
-			|| defined( 'WP_CLI' ) && WP_CLI
+			( defined( 'DOING_AJAX' ) && DOING_AJAX )
+			|| ( defined( 'DOING_CRON' ) && DOING_CRON )
+			|| ( defined( 'WP_CLI' ) && WP_CLI )
 		) {
 			return;
 		}
@@ -69,6 +97,11 @@ class WC_Admin_Marketplace_Promotions {

 		self::$locale = ( self::$locale ?? get_user_locale() ) ?? 'en_US';
 		self::maybe_show_bubble_promotions();
+
+		// Contextual promo card on the Orders list (a classic, non-SPA admin page). The script
+		// inserts the card above the orders table using the promo data localized in the enqueue
+		// step, which works the same on HPOS and the legacy posts list.
+		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_orders_promo_card' ) );
 	}

 	/**
@@ -117,10 +150,328 @@ class WC_Admin_Marketplace_Promotions {
 		}

 		$promotions = self::merge_promos( $promotions );
+		$promotions = self::resolve_rule_based_promotions( $promotions );

 		return self::filter_out_inactive_promotions( $promotions );
 	}

+	/**
+	 * Enqueue the Orders-screen promo card script and localize the resolved promo.
+	 *
+	 * The Orders list is a classic admin page, so the card is mounted by a wp-admin-scripts
+	 * entry that inserts it above the orders table (see ShippingLabelBanner for the same enqueue
+	 * pattern). The promotion is rule-resolved server-side and passed to the script, so no
+	 * additional data is shared with WooCommerce.com.
+	 *
+	 * @return void
+	 *
+	 * @since 11.0.0
+	 */
+	public static function maybe_enqueue_orders_promo_card() {
+		if ( ! self::is_orders_screen() ) {
+			return;
+		}
+
+		$card = self::get_orders_promo_card();
+		if ( null === $card ) {
+			return;
+		}
+
+		WCAdminAssets::register_script( 'wp-admin-scripts', 'marketplace-orders-promo', true );
+
+		// Pass the REST dismiss URL + nonce so the script persists a dismissal with a plain fetch.
+		// rest_url() yields a working URL under any permalink structure, and this avoids depending
+		// on apiFetch being configured on this classic (non-SPA) admin page.
+		$payload = array_merge(
+			$card,
+			array(
+				'dismiss_url'   => esc_url_raw( rest_url( 'wc-admin/marketplace-promotions/dismiss' ) ),
+				'dismiss_nonce' => wp_create_nonce( 'wp_rest' ),
+			)
+		);
+		wp_add_inline_script(
+			'wc-admin-marketplace-orders-promo',
+			'window.wcOrdersPromo = ' . wp_json_encode( $payload, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ';',
+			'before'
+		);
+
+		// Enqueue the card stylesheet under a unique handle. WCAdminAssets::register_style() would
+		// register it as the shared `wc-admin-style` handle, which collides with other Orders-screen
+		// styles (e.g. Fulfillments) so that whichever enqueues first wins and the other is dropped.
+		$style_handle = 'wc-admin-marketplace-orders-promo';
+		wp_enqueue_style(
+			$style_handle,
+			WCAdminAssets::get_url( 'marketplace-orders-promo/style', 'css' ),
+			array( 'wp-components' ),
+			WCAdminAssets::get_file_version( 'css' )
+		);
+		wp_style_add_data( $style_handle, 'rtl', 'replace' );
+	}
+
+	/**
+	 * Whether the current screen is the WooCommerce Orders list (HPOS or legacy).
+	 *
+	 * @return bool
+	 */
+	private static function is_orders_screen(): bool {
+		$screen = get_current_screen();
+		if ( ! $screen ) {
+			return false;
+		}
+
+		return in_array(
+			$screen->id,
+			array( 'woocommerce_page_wc-orders', 'admin_page_wc-orders', 'edit-shop_order' ),
+			true
+		);
+	}
+
+	/**
+	 * Get the first active promo card that targets the Orders screen, together with the
+	 * store's order count for instrumentation. Returns null when none is eligible.
+	 *
+	 * Resolved once per request (the enqueue and render hooks both need it).
+	 *
+	 * @internal Not a supported extension point.
+	 *
+	 * @return array|null
+	 */
+	public static function get_orders_promo_card() {
+		if ( self::$orders_promo_card_resolved ) {
+			return self::$orders_promo_card;
+		}
+
+		self::$orders_promo_card_resolved = true;
+
+		$dismissed = self::get_dismissed_promo_ids();
+
+		foreach ( self::get_active_promotions() as $promotion ) {
+			if ( ! is_array( $promotion ) || 'promo-card' !== ( $promotion['format'] ?? '' ) ) {
+				continue;
+			}
+
+			if ( ! self::promotion_targets_orders( $promotion ) ) {
+				continue;
+			}
+
+			// An id is required so the card can be dismissed permanently; skip ones already dismissed.
+			$id = isset( $promotion['id'] ) ? (string) $promotion['id'] : '';
+			if ( '' === $id || in_array( $id, $dismissed, true ) ) {
+				continue;
+			}
+
+			self::$orders_promo_card = array(
+				'id'          => $id,
+				'promotion'   => $promotion,
+				'order_count' => ( new OrdersProvider() )->get_order_count(),
+			);
+			break;
+		}
+
+		return self::$orders_promo_card;
+	}
+
+	/**
+	 * Whether a promotion declares the Orders screen as a placement, via a
+	 * `pages` entry of `{ "page": "wc-orders" }`.
+	 *
+	 * @param array $promotion The promotion definition.
+	 * @return bool
+	 */
+	private static function promotion_targets_orders( array $promotion ): bool {
+		foreach ( (array) ( $promotion['pages'] ?? array() ) as $page ) {
+			if ( is_array( $page ) && 'wc-orders' === ( $page['page'] ?? '' ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Get the promo ids the current user has permanently dismissed.
+	 *
+	 * @return string[]
+	 */
+	private static function get_dismissed_promo_ids(): array {
+		$dismissed = get_user_meta( get_current_user_id(), self::DISMISSED_PROMOS_META, true );
+
+		return is_array( $dismissed ) ? array_values( array_filter( array_map( 'strval', $dismissed ) ) ) : array();
+	}
+
+	/**
+	 * Register the REST route used to permanently dismiss an Orders promo card.
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public static function register_rest_routes() {
+		register_rest_route(
+			'wc-admin',
+			'/marketplace-promotions/dismiss',
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( __CLASS__, 'handle_dismiss_request' ),
+				'permission_callback' => static function () {
+					return current_user_can( 'manage_woocommerce' );
+				},
+				'args'                => array(
+					'id' => array(
+						'type'              => 'string',
+						'required'          => true,
+						'sanitize_callback' => 'sanitize_text_field',
+						'validate_callback' => static function ( $value ) {
+							return is_string( $value ) && '' !== trim( $value );
+						},
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Permanently dismiss a promo card for the current user.
+	 *
+	 * @internal
+	 *
+	 * @param \WP_REST_Request<array<string, mixed>> $request The dismiss request.
+	 * @return \WP_REST_Response
+	 */
+	public static function handle_dismiss_request( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		$id        = (string) $request->get_param( 'id' );
+		$dismissed = self::get_dismissed_promo_ids();
+
+		if ( ! in_array( $id, $dismissed, true ) ) {
+			$dismissed[] = $id;
+			update_user_meta( get_current_user_id(), self::DISMISSED_PROMOS_META, $dismissed );
+		}
+
+		return rest_ensure_response( array( 'dismissed' => true ) );
+	}
+
+	/**
+	 * Evaluate locally targeted promotions before they are exposed to JS.
+	 *
+	 * Supported stores convert matching rule-based promos into standard promo cards.
+	 * Unsupported stores ignore the custom format entirely.
+	 *
+	 * @param array $promotions Promotions data received from WCCOM.
+	 * @return array
+	 */
+	private static function resolve_rule_based_promotions( array $promotions ): array {
+		$resolved_promotions = array();
+
+		foreach ( $promotions as $promotion ) {
+			if ( ! is_array( $promotion ) ) {
+				$resolved_promotions[] = $promotion;
+				continue;
+			}
+
+			if ( self::RULE_BASED_FORMAT !== ( $promotion['format'] ?? '' ) ) {
+				$resolved_promotions[] = $promotion;
+				continue;
+			}
+
+			if ( ! self::promotion_rules_pass( $promotion['local_rules'] ?? array() ) ) {
+				continue;
+			}
+
+			unset( $promotion['local_rules'] );
+			$promotion['format']   = 'promo-card';
+			$resolved_promotions[] = $promotion;
+		}
+
+		return $resolved_promotions;
+	}
+
+	/**
+	 * Evaluate local_rules using the WooCommerce Admin remote specs rule schema.
+	 *
+	 * WCCOM payloads must provide rules compatible with the existing Core rule
+	 * processors. Unknown or malformed rules fail closed.
+	 *
+	 * @param mixed $rules Rule definitions from the promotions payload.
+	 * @return bool
+	 */
+	private static function promotion_rules_pass( $rules ): bool {
+		if ( ! is_array( $rules ) || empty( $rules ) ) {
+			return false;
+		}
+
+		$encoded_rules = wp_json_encode( $rules );
+		if ( false === $encoded_rules ) {
+			return false;
+		}
+
+		$decoded_rules = json_decode( $encoded_rules );
+		if ( ! self::rules_are_valid( $decoded_rules ) ) {
+			return false;
+		}
+
+		return ( new RuleEvaluator() )
+			->evaluate( $decoded_rules );
+	}
+
+	/**
+	 * Recursively validate a rule (or array of rules) before evaluation.
+	 *
+	 * Validation must cover nested `not`/`or` operands: an empty or malformed operand
+	 * evaluates to false, and `not` would then flip that to true, showing the promo on a
+	 * malformed payload. Unknown rule types resolve to a fail processor (which validates
+	 * but always fails), so they are rejected here too. Anything not well-formed fails closed.
+	 *
+	 * @param mixed $rules A decoded rule object or array of rule objects.
+	 * @return bool
+	 */
+	private static function rules_are_valid( $rules ): bool {
+		if ( is_object( $rules ) ) {
+			$rules = array( $rules );
+		}
+
+		if ( ! is_array( $rules ) || 0 === count( $rules ) ) {
+			return false;
+		}
+
+		foreach ( $rules as $rule ) {
+			if ( ! is_object( $rule ) || empty( $rule->type ) ) {
+				return false;
+			}
+
+			$processor = GetRuleProcessor::get_processor( $rule->type );
+
+			// Unknown types resolve to the fail processor; reject them so `not` cannot flip them to true.
+			if ( $processor instanceof FailRuleProcessor
+				&& 'fail' !== $rule->type ) {
+				return false;
+			}
+
+			if ( ! $processor->validate( $rule ) ) {
+				return false;
+			}
+
+			if ( 'not' === $rule->type && ! self::rules_are_valid( $rule->operand ?? null ) ) {
+				return false;
+			}
+
+			if ( 'or' === $rule->type ) {
+				$operands = $rule->operands ?? null;
+				if ( ! is_array( $operands ) || 0 === count( $operands ) ) {
+					return false;
+				}
+
+				// Each OR operand may itself be a single rule or an AND group (array of rules).
+				foreach ( $operands as $operand ) {
+					if ( ! self::rules_are_valid( $operand ) ) {
+						return false;
+					}
+				}
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * Get promotions to show in the Woo in-app marketplace and load them into a transient
 	 * with a 12-hour life. Run as a recurring scheduled action.
@@ -253,7 +604,7 @@ class WC_Admin_Marketplace_Promotions {

 		return array_filter(
 			$promotions,
-			function( $promotion ) use ( $format ) {
+			function ( $promotion ) use ( $format ) {
 				return isset( $promotion['format'] ) && $format === $promotion['format'];
 			}
 		);
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-marketplace-promotions-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-marketplace-promotions-test.php
new file mode 100644
index 00000000000..c73cf032e7f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-marketplace-promotions-test.php
@@ -0,0 +1,410 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Admin_Marketplace_Promotions rule-based promo cards.
+ *
+ * The transport and rule engine are plugin-agnostic; the pilot payload (WCCOM-2634)
+ * targets Product Add-Ons. Production thresholds are order_count >= 100 and
+ * product_count >= 20 (see the wccom_iam_promos payload). These tests assert the rule
+ * *shape* resolves correctly using clean-environment thresholds, plus the gating
+ * behaviour in both directions, so they do not depend on seeded order/product volume.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+class WC_Admin_Marketplace_Promotions_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Add-on plugin directory slugs excluded by the pilot rule (official + competitors).
+	 *
+	 * plugins_activated matches the plugin directory slug, not the plugin file path.
+	 *
+	 * @var string[]
+	 */
+	private const ADD_ON_SLUGS = array(
+		'woocommerce-product-addons',
+		'advanced-product-fields-for-woocommerce',
+		'woo-custom-product-addons',
+		'woo-custom-product-addons-pro',
+		'woo-extra-product-options',
+		'woocommerce-tm-extra-product-options',
+		'yith-woocommerce-product-add-ons',
+		'woocommerce-product-addon',
+	);
+
+	/**
+	 * Callback registered on option_active_plugins by tests that need a plugin to read as active.
+	 *
+	 * @var callable|null
+	 */
+	private $active_plugins_filter = null;
+
+	/**
+	 * Clean up state between tests.
+	 */
+	public function tearDown(): void {
+		delete_transient( WC_Admin_Marketplace_Promotions::TRANSIENT_NAME );
+		delete_option( 'woocommerce_allow_tracking' );
+		if ( null !== $this->active_plugins_filter ) {
+			remove_filter( 'option_active_plugins', $this->active_plugins_filter );
+			$this->active_plugins_filter = null;
+		}
+		$this->reset_orders_promo_cache();
+		wp_set_current_user( 0 );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Clear the request-scoped Orders promo card cache between tests.
+	 */
+	private function reset_orders_promo_cache(): void {
+		$resolved = new ReflectionProperty( WC_Admin_Marketplace_Promotions::class, 'orders_promo_card_resolved' );
+		$resolved->setAccessible( true );
+		$resolved->setValue( null, false );
+
+		$card = new ReflectionProperty( WC_Admin_Marketplace_Promotions::class, 'orders_promo_card' );
+		$card->setAccessible( true );
+		$card->setValue( null, null );
+	}
+
+	/**
+	 * Build the "none of these add-on plugins active" exclusion rule.
+	 *
+	 * @param string[] $extra_slugs Extra directory slugs to add to the exclusion list.
+	 * @return array
+	 */
+	private function add_on_exclusion_rule( array $extra_slugs = array() ): array {
+		$operands = array_map(
+			static function ( string $slug ): array {
+				return array(
+					'type'    => 'plugins_activated',
+					'plugins' => array( $slug ),
+				);
+			},
+			array_merge( self::ADD_ON_SLUGS, $extra_slugs )
+		);
+
+		return array(
+			'type'    => 'not',
+			'operand' => array(
+				'type'     => 'or',
+				'operands' => $operands,
+			),
+		);
+	}
+
+	/**
+	 * Store a single rule-based Product Add-Ons promo in the promotions transient.
+	 *
+	 * @param array $local_rules The local_rules to attach.
+	 * @param array $pages       The pages the promo targets.
+	 */
+	private function set_rule_based_promo( array $local_rules, array $pages = array(
+		array(
+			'page' => 'wc-admin',
+			'path' => '/',
+		),
+	) ): void {
+		set_transient(
+			WC_Admin_Marketplace_Promotions::TRANSIENT_NAME,
+			array(
+				array(
+					'id'            => 'product-add-ons-orders',
+					'date_from_gmt' => '2025-01-01 00:00:00',
+					'date_to_gmt'   => '2099-01-01 00:00:00',
+					'format'        => WC_Admin_Marketplace_Promotions::RULE_BASED_FORMAT,
+					'pages'         => $pages,
+					'title'         => array( 'en_US' => 'Add options and personalization to your products' ),
+					'content'       => array( 'en_US' => 'Let customers add gift wrapping, custom text, file uploads, or paid options right on the product page.' ),
+					'cta_label'     => array( 'en_US' => 'See Product Add-Ons' ),
+					'cta_link'      => 'https://woocommerce.com/products/product-add-ons/',
+					'local_rules'   => $local_rules,
+				),
+			)
+		);
+	}
+
+	/**
+	 * The pilot rule set, with order/product thresholds overridable so the engine can be
+	 * exercised without seeding production-scale volume.
+	 *
+	 * @param int $order_threshold   order_count >= value.
+	 * @param int $product_threshold product_count >= value.
+	 * @return array
+	 */
+	private function pilot_rules( int $order_threshold = 0, int $product_threshold = 0 ): array {
+		return array(
+			array(
+				'type'        => 'option',
+				'option_name' => 'woocommerce_allow_tracking',
+				'operation'   => '=',
+				'value'       => 'yes',
+			),
+			array(
+				'type'      => 'order_count',
+				'operation' => '>=',
+				'value'     => $order_threshold,
+			),
+			array(
+				'type'      => 'product_count',
+				'operation' => '>=',
+				'value'     => $product_threshold,
+			),
+			$this->add_on_exclusion_rule(),
+		);
+	}
+
+	/**
+	 * @testdox Eligible Product Add-Ons promo is converted into a promo card and local_rules stripped.
+	 */
+	public function test_eligible_rule_based_promo_is_converted_to_promo_card(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+
+		$this->set_rule_based_promo( $this->pilot_rules() );
+
+		$promotions = WC_Admin_Marketplace_Promotions::get_active_promotions();
+
+		$this->assertCount( 1, $promotions );
+		$this->assertSame( 'promo-card', $promotions[0]['format'] );
+		$this->assertArrayNotHasKey( 'local_rules', $promotions[0] );
+		$this->assertSame( 'See Product Add-Ons', $promotions[0]['cta_label']['en_US'] );
+	}
+
+	/**
+	 * @testdox Promo is suppressed when the store already has an add-on plugin active.
+	 */
+	public function test_active_add_on_plugin_suppresses_promo(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+
+		// Force WooCommerce itself to read as an active plugin, then include its slug in the
+		// exclusion list. This proves not[ or[ plugins_activated... ] ] suppresses when ANY
+		// listed plugin is active (the encoding that a single plugins_activated list would get wrong).
+		// The callback is stored so tearDown removes only it, not unrelated filters on the hook.
+		$this->active_plugins_filter = static function (): array {
+			return array( 'woocommerce/woocommerce.php' );
+		};
+		add_filter( 'option_active_plugins', $this->active_plugins_filter );
+
+		$rules    = $this->pilot_rules();
+		$rules[3] = $this->add_on_exclusion_rule( array( 'woocommerce' ) );
+		$this->set_rule_based_promo( $rules );
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox Promo is suppressed when product_count is below the threshold.
+	 */
+	public function test_product_count_below_threshold_suppresses_promo(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+
+		// Clean test store has fewer than 9999 published products.
+		$this->set_rule_based_promo( $this->pilot_rules( 0, 9999 ) );
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox Promo is suppressed when tracking is not opted in.
+	 */
+	public function test_tracking_opt_out_suppresses_promo(): void {
+		delete_option( 'woocommerce_allow_tracking' );
+
+		$this->set_rule_based_promo( $this->pilot_rules() );
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox Rule-based promotions are suppressed when local rules explicitly fail.
+	 */
+	public function test_rule_based_promotions_are_suppressed_when_rules_fail(): void {
+		$this->set_rule_based_promo( array( array( 'type' => 'fail' ) ) );
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox Malformed rule-based promotions fail closed.
+	 */
+	public function test_malformed_rule_based_promotions_fail_closed(): void {
+		// order_count with no value/operation is invalid and must not pass.
+		$this->set_rule_based_promo( array( array( 'type' => 'order_count' ) ) );
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox A `not` rule with an empty operand fails closed (does not flip to true).
+	 */
+	public function test_not_rule_with_empty_operand_fails_closed(): void {
+		// not( [] ) would evaluate to true (RuleEvaluator returns false for an empty rule set,
+		// which `not` flips), so the validator must reject the empty operand first.
+		$this->set_rule_based_promo(
+			array(
+				array(
+					'type'    => 'not',
+					'operand' => array(),
+				),
+			)
+		);
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox A `not` rule wrapping an unknown rule type fails closed.
+	 */
+	public function test_not_rule_with_unknown_nested_type_fails_closed(): void {
+		// An unknown type resolves to the fail processor; without rejecting it, `not` would
+		// flip its failure into a pass.
+		$this->set_rule_based_promo(
+			array(
+				array(
+					'type'    => 'not',
+					'operand' => array( 'type' => 'totally_unknown_rule_type' ),
+				),
+			)
+		);
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox A `not` rule wrapping an empty `or` fails closed.
+	 */
+	public function test_not_rule_with_empty_nested_or_fails_closed(): void {
+		$this->set_rule_based_promo(
+			array(
+				array(
+					'type'    => 'not',
+					'operand' => array(
+						'type'     => 'or',
+						'operands' => array(),
+					),
+				),
+			)
+		);
+
+		$this->assertSame( array(), WC_Admin_Marketplace_Promotions::get_active_promotions() );
+	}
+
+	/**
+	 * @testdox An OR rule whose operands are AND groups (arrays of rules) is accepted.
+	 */
+	public function test_or_rule_with_and_group_operands_is_accepted(): void {
+		// RemoteSpecs allows each OR operand to be an AND group (array of rules), not only a
+		// single rule. The validator must accept that shape so RuleEvaluator can evaluate it.
+		$this->set_rule_based_promo(
+			array(
+				array(
+					'type'     => 'or',
+					// Each operand is an AND group (array of rules); the first passes, so OR passes.
+					'operands' => array(
+						array( array( 'type' => 'pass' ) ),
+						array( array( 'type' => 'fail' ) ),
+					),
+				),
+			)
+		);
+
+		$promotions = WC_Admin_Marketplace_Promotions::get_active_promotions();
+
+		$this->assertCount( 1, $promotions );
+		$this->assertSame( 'promo-card', $promotions[0]['format'] );
+	}
+
+	/**
+	 * @testdox An eligible promo targeting the Orders screen is returned with the order count.
+	 */
+	public function test_orders_promo_card_returned_when_targeting_orders(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+
+		$this->set_rule_based_promo( $this->pilot_rules(), array( array( 'page' => 'wc-orders' ) ) );
+
+		$card = WC_Admin_Marketplace_Promotions::get_orders_promo_card();
+
+		$this->assertIsArray( $card );
+		$this->assertSame( 'product-add-ons-orders', $card['id'] );
+		$this->assertSame( 'promo-card', $card['promotion']['format'] );
+		$this->assertSame( 'See Product Add-Ons', $card['promotion']['cta_label']['en_US'] );
+		$this->assertArrayHasKey( 'order_count', $card );
+		$this->assertIsInt( $card['order_count'] );
+	}
+
+	/**
+	 * @testdox A promo that targets only the Marketplace is not shown on the Orders screen.
+	 */
+	public function test_orders_promo_card_null_when_not_targeting_orders(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+
+		// Default pages target the Marketplace app (page=wc-admin), not the Orders list.
+		$this->set_rule_based_promo( $this->pilot_rules() );
+
+		$this->assertNull( WC_Admin_Marketplace_Promotions::get_orders_promo_card() );
+	}
+
+	/**
+	 * @testdox No Orders promo card is returned when no promotion is active.
+	 */
+	public function test_orders_promo_card_null_when_no_promotions(): void {
+		$this->assertNull( WC_Admin_Marketplace_Promotions::get_orders_promo_card() );
+	}
+
+	/**
+	 * @testdox An Orders promo without an id is never shown (it could not be dismissed).
+	 */
+	public function test_orders_promo_card_requires_an_id(): void {
+		set_transient(
+			WC_Admin_Marketplace_Promotions::TRANSIENT_NAME,
+			array(
+				array(
+					'date_from_gmt' => '2025-01-01 00:00:00',
+					'date_to_gmt'   => '2099-01-01 00:00:00',
+					'format'        => 'promo-card',
+					'pages'         => array( array( 'page' => 'wc-orders' ) ),
+					'title'         => array( 'en_US' => 'No id here' ),
+				),
+			)
+		);
+
+		$this->assertNull( WC_Admin_Marketplace_Promotions::get_orders_promo_card() );
+	}
+
+	/**
+	 * @testdox A promo the current user has dismissed is not shown.
+	 */
+	public function test_orders_promo_card_skipped_when_dismissed(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $user_id );
+		update_user_meta( $user_id, WC_Admin_Marketplace_Promotions::DISMISSED_PROMOS_META, array( 'product-add-ons-orders' ) );
+
+		$this->set_rule_based_promo( $this->pilot_rules(), array( array( 'page' => 'wc-orders' ) ) );
+
+		$this->assertNull( WC_Admin_Marketplace_Promotions::get_orders_promo_card() );
+	}
+
+	/**
+	 * @testdox The dismiss endpoint records the promo id once, per user.
+	 */
+	public function test_dismiss_request_persists_per_user(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $user_id );
+
+		$request = new WP_REST_Request( 'POST', '/wc-admin/marketplace-promotions/dismiss' );
+		$request->set_param( 'id', 'product-add-ons-orders' );
+
+		$response = WC_Admin_Marketplace_Promotions::handle_dismiss_request( $request );
+		$this->assertTrue( $response->get_data()['dismissed'] );
+
+		$dismissed = get_user_meta( $user_id, WC_Admin_Marketplace_Promotions::DISMISSED_PROMOS_META, true );
+		$this->assertSame( array( 'product-add-ons-orders' ), $dismissed );
+
+		// Idempotent: dismissing again does not duplicate the id.
+		WC_Admin_Marketplace_Promotions::handle_dismiss_request( $request );
+		$this->assertCount( 1, get_user_meta( $user_id, WC_Admin_Marketplace_Promotions::DISMISSED_PROMOS_META, true ) );
+	}
+}