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 ) );
+ }
+}