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> $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( '|[A-z0-9 <>="-]+|', '', $menu_item_text ); return $menu_item_text . '' . esc_html( $bubble_text ) . ''; } /** * 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' ); } } } // Fetch list of promotions from WooCommerce.com for WooCommerce admin UI. if ( ! has_action( 'init', array( 'WC_Admin_Marketplace_Promotions', 'init' ) ) ) { add_action( 'init', array( 'WC_Admin_Marketplace_Promotions', 'init' ), 11 ); }