WC_Admin_Marketplace_Promotions{}WC 1.0

WC_Admin_Marketplace_Promotions class.

Usage

$WC_Admin_Marketplace_Promotions = new WC_Admin_Marketplace_Promotions();
// use class methods

Methods

  1. public static init()
  2. public static update_promotions()
  3. public static get_active_promotions()
  4. public static maybe_enqueue_orders_promo_card()
  5. public static get_orders_promo_card()
  6. public static register_rest_routes()
  7. public static handle_dismiss_request( \WP_REST_Request $request )
  8. public static filter_marketplace_menu_items( $menu_items, $promotion = array() )
  9. public static clear_cron_event()
  10. public static clear_deprecated_scheduled_event()
  11. public static clear_deprecated_action()
  12. private static schedule_cron_event()
  13. private static is_orders_screen()
  14. private static promotion_targets_orders( array $promotion )
  15. private static get_dismissed_promo_ids()
  16. private static resolve_rule_based_promotions( array $promotions )
  17. private static promotion_rules_pass( $rules )
  18. private static rules_are_valid( $rules )
  19. private static fetch_marketplace_promotions()
  20. private static maybe_show_bubble_promotions()
  21. private static get_promotions_of_format( $promotions = array(), $format = '' )
  22. private static filter_out_inactive_promotions( $promotions = array() )
  23. private static append_bubble( string $menu_item_text, string $bubble_text )

WC_Admin_Marketplace_Promotions{} code WC 11.0.0

class WC_Admin_Marketplace_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.
	 *
	 * @var string
	 */
	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
	 * if the promotions API requests them.
	 *
	 * WC_Admin calls this method when it is instantiated during
	 * is_admin requests.
	 *
	 * @return void
	 */
	public static function init() {
		// A legacy hook that can be triggered by action scheduler.
		add_action( 'woocommerce_marketplace_fetch_promotions', array( __CLASS__, 'clear_deprecated_action' ) );
		add_action(
			'woocommerce_marketplace_fetch_promotions_clear',
			array(
				__CLASS__,
				'clear_deprecated_scheduled_event',
			)
		);

		// 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 )
		) {
			return;
		}

		if ( ! is_admin() ) {
			return;
		}

		self::schedule_cron_event();

		register_deactivation_hook( WC_PLUGIN_FILE, array( __CLASS__, 'clear_cron_event' ) );

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

	/**
	 * Schedule a daily cron event to fetch promotions.
	 *
	 * @version 9.5.0
	 *
	 * @return void
	 */
	private static function schedule_cron_event() {
		if ( ! wp_next_scheduled( self::CRON_NAME ) ) {
			wp_schedule_event( time(), 'twicedaily', self::CRON_NAME );
		}
	}

	/**
	 * Fetch promotions from the API and store them in a transient.
	 *
	 * @return void
	 */
	public static function update_promotions() {
		// Fetch promotions from the API.
		$promotions = self::fetch_marketplace_promotions();
		set_transient( self::TRANSIENT_NAME, $promotions, self::TRANSIENT_LIFE_SPAN );
	}

	/**
	 * Get active Marketplace promotions from the transient.
	 * Use `woocommerce_marketplace_suppress_promotions` filter to suppress promotions.
	 *
	 * @since 9.0
	 */
	public static function get_active_promotions() {
		/**
		 * Filter to suppress the requests for and showing of marketplace promotions.
		 *
		 * @since 8.8
		 */
		if ( apply_filters( 'woocommerce_marketplace_suppress_promotions', false ) ) {
			return array();
		}

		$promotions = get_transient( self::TRANSIENT_NAME );
		if ( ! $promotions ) {
			return array();
		}

		$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.
	 *
	 * @return array
	 */
	private static function fetch_marketplace_promotions() {
		/**
		 * Filter to suppress the requests for and showing of marketplace promotions.
		 *
		 * @since 8.8
		 */
		if ( apply_filters( 'woocommerce_marketplace_suppress_promotions', false ) ) {
			return array();
		}

		// Fetch promotions from the API.
		$fetch_options  = array(
			'auth'    => true,
			'country' => true,
		);
		$raw_promotions = WC_Admin_Addons::fetch( self::PROMOTIONS_API_URL, $fetch_options );

		// phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores
		if ( is_wp_error( $raw_promotions ) ) {
			/**
			 * Allows connection error to be handled.
			 *
			 * @since 8.7
			 */
			do_action( 'woocommerce_page_wc-addons_connection_error', $raw_promotions->get_error_message() );
		}

		$response_code = (int) wp_remote_retrieve_response_code( $raw_promotions );
		if ( 200 !== $response_code ) {
			/**
			 * Allows connection error to be handled.
			 *
			 * @since 8.7
			 */
			do_action( 'woocommerce_page_wc-addons_connection_error', $response_code );
		}

		$promotions = json_decode( wp_remote_retrieve_body( $raw_promotions ), true );

		if ( ! is_array( $promotions ) ) {
			$promotions = array();

			/**
			 * Allows connection error to be handled.
			 *
			 * @since 8.7
			 */
			do_action( 'woocommerce_page_wc-addons_connection_error', 'Malformed response' );
		}
		// phpcs:enable WordPress.NamingConventions.ValidHookName.UseUnderscores

		return $promotions;
	}

	/**
	 * If there's an active promotion of the format `menu_bubble`,
	 * add a filter to show a bubble on the Extensions item in the
	 * WooCommerce menu.
	 *
	 * Use `woocommerce_marketplace_suppress_promotions` filter to suppress the bubble.
	 *
	 * @return void
	 * @throws Exception  If we are unable to create a DateTime from the date_to_gmt.
	 */
	private static function maybe_show_bubble_promotions() {
		/**
		 * Filter to suppress the requests for and showing of marketplace promotions.
		 *
		 * @since 8.8
		 */
		if ( apply_filters( 'woocommerce_marketplace_suppress_promotions', false ) ) {
			return;
		}

		$promotions = get_transient( self::TRANSIENT_NAME );
		if ( ! $promotions ) {
			return;
		}

		$bubble_promotions = self::get_promotions_of_format( $promotions, 'menu_bubble' );
		if ( empty( $bubble_promotions ) ) {
			return;
		}

		$now_date_time = new DateTime( 'now', new DateTimeZone( 'UTC' ) );

		// Let's make absolutely sure the promotion is still active.
		foreach ( $bubble_promotions as $promotion ) {
			if ( ! isset( $promotion['date_to_gmt'] ) ) {
				continue;
			}

			try {
				$date_to_gmt = new DateTime( $promotion['date_to_gmt'], new DateTimeZone( 'UTC' ) );
			} catch ( \Exception $ex ) {
				continue;
			}

			if ( $now_date_time < $date_to_gmt ) {
				add_filter(
					'woocommerce_marketplace_menu_items',
					function ( $marketplace_pages ) use ( $promotion ) {
						return self::filter_marketplace_menu_items( $marketplace_pages, $promotion );
					}
				);

				break;
			}
		}
	}

	/**
	 * From the array of promotions, select those of a given format.
	 *
	 * @param ?array  $promotions  Array of data about promotions of all formats.
	 * @param ?string $format      Format we want to filter for.
	 *
	 * @return array
	 */
	private static function get_promotions_of_format( $promotions = array(), $format = '' ): array {
		if ( empty( $promotions ) || empty( $format ) ) {
			return array();
		}

		return array_filter(
			$promotions,
			function ( $promotion ) use ( $format ) {
				return isset( $promotion['format'] ) && $format === $promotion['format'];
			}
		);
	}

	/**
	 * Find promotions that are still active – they have a date range that
	 * includes the current date.
	 *
	 * @param ?array $promotions  Data about current promotions.
	 *
	 * @return array
	 */
	private static function filter_out_inactive_promotions( $promotions = array() ) {
		$now_date_time     = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
		$active_promotions = array();

		foreach ( $promotions as $promotion ) {
			if ( ! isset( $promotion['date_from_gmt'] ) || ! isset( $promotion['date_to_gmt'] ) ) {
				continue;
			}

			try {
				$date_from_gmt = new DateTime( $promotion['date_from_gmt'], new DateTimeZone( 'UTC' ) );
				$date_to_gmt   = new DateTime( $promotion['date_to_gmt'], new DateTimeZone( 'UTC' ) );
			} catch ( \Exception $ex ) {
				continue;
			}

			if ( $now_date_time >= $date_from_gmt && $now_date_time <= $date_to_gmt ) {
				$active_promotions[] = $promotion;
			}
		}

		// Sort promotions so the ones starting more recently are at the top.
		usort(
			$active_promotions,
			function ( $a, $b ) {
				return $b['date_from_gmt'] <=> $a['date_from_gmt'];
			}
		);

		return $active_promotions;
	}

	/**
	 * Promos arrive in the array of promotions as an array of arrays with the key 'promos'.
	 * We merge them into the main array.
	 *
	 * @param ?array $promotions  Promotions data received from WCCOM.
	 *                            May have an element with the key 'promos', which contains an array.
	 *
	 * @return array
	 * */
	private static function merge_promos( ?array $promotions = array() ): array {
		if (
			! empty( $promotions['promos'] )
			&& is_array( $promotions['promos'] )
		) {
			$promotions = array_merge( $promotions, $promotions['promos'] );
			unset( $promotions['promos'] );
		}

		return $promotions;
	}

	/**
	 * Callback for the `woocommerce_marketplace_menu_items` filter
	 * in `Automattic\WooCommerce\Internal\Admin\Marketplace::get_marketplace_pages`.
	 * At the moment, the Extensions page is the only page in `$menu_items`.
	 * Adds a bubble to the menu item.
	 *
	 * @param array  $menu_items  Arrays representing items in nav menu.
	 * @param ?array $promotion   Data about a promotion from the WooCommerce.com API.
	 *
	 * @return array
	 */
	public static function filter_marketplace_menu_items( $menu_items, $promotion = array() ): array {
		if ( ! isset( $promotion['menu_item_id'] ) || ! isset( $promotion['content'] ) ) {
			return $menu_items;
		}
		foreach ( $menu_items as $index => $menu_item ) {
			if (
				'woocommerce' === $menu_item['parent']
				&& $promotion['menu_item_id'] === $menu_item['id']
			) {
				$bubble_text                   = $promotion['content'][ self::$locale ] ?? ( $promotion['content']['en_US'] ?? __( 'Sale', 'woocommerce' ) );
				$menu_items[ $index ]['title'] = self::append_bubble( $menu_item['title'], $bubble_text );

				break;
			}
		}

		return $menu_items;
	}

	/**
	 * Return the markup for a menu item bubble with a given text.
	 *
	 * @param string $menu_item_text Text of menu item we want to change.
	 * @param string $bubble_text    Text of bubble.
	 *
	 * @return string
	 */
	private static function append_bubble( string $menu_item_text, string $bubble_text ): string {
		// Strip out update count bubble added by Marketplace::get_marketplace_update_count_html.
		$menu_item_text = preg_replace( '|<span class="update-plugins count-[\d]+">[A-z0-9 <>="-]+</span>|', '', $menu_item_text );

		return $menu_item_text
			. '<span class="update-plugins remaining-tasks-badge woocommerce-task-list-remaining-tasks-badge">'
			. esc_html( $bubble_text )
			. '</span>';
	}

	/**
	 * When WooCommerce is disabled, clear the WP Cron event we use to fetch promotions.
	 *
	 * @version 9.5.0
	 *
	 * @return void
	 */
	public static function clear_cron_event() {
		$timestamp = wp_next_scheduled( self::CRON_NAME );
		wp_unschedule_event( $timestamp, self::CRON_NAME );
	}

	/**
	 * Clear deprecated scheduled action that was used to fetch promotions in WooCommerce 8.8.
	 * Replaced with a transient in WooCommerce 9.0.
	 *
	 * @return void
	 */
	public static function clear_deprecated_scheduled_event() {
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( 'woocommerce_marketplace_fetch_promotions' );
		}
	}

	/**
	 * We can't clear deprecated action from AS when it's running,
	 * so we schedule a new single action to clear the deprecated
	 * `woocommerce_marketplace_fetch_promotions` action.
	 */
	public static function clear_deprecated_action() {
		if ( function_exists( 'as_schedule_single_action' ) ) {
			as_schedule_single_action( time(), 'woocommerce_marketplace_fetch_promotions_clear' );
		}
	}
}