first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
<?php
/**
* Abilities Categories class file.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities;
defined( 'ABSPATH' ) || exit;
/**
* Abilities Categories class for WooCommerce.
*
* Registers categories for WooCommerce abilities to improve organization
* and discoverability in the WordPress Abilities API v0.3.0+.
*/
class AbilitiesCategories {
/**
* Initialize category registration.
*
* @internal
*/
final public static function init(): void {
/*
* Register categories when Abilities API categories are ready.
* Support both old (pre-6.9) and new (6.9+) action names.
*/
add_action( 'abilities_api_categories_init', array( __CLASS__, 'register_categories' ) );
add_action( 'wp_abilities_api_categories_init', array( __CLASS__, 'register_categories' ) );
}
/**
* Register WooCommerce ability categories.
*/
public static function register_categories(): void {
// Only register if the function exists.
if ( ! function_exists( 'wp_register_ability_category' ) ) {
return;
}
wp_register_ability_category(
'woocommerce-rest',
array(
'label' => __( 'WooCommerce REST API', 'woocommerce' ),
'description' => __( 'REST API operations for WooCommerce resources including products, orders, and other store data.', 'woocommerce' ),
)
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Abilities Registry class file.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities;
defined( 'ABSPATH' ) || exit;
/**
* Abilities Registry class for WooCommerce.
*
* Centralized registry that initializes all WooCommerce abilities.
* These abilities can be consumed by MCP, REST API, or other tools.
*/
class AbilitiesRegistry {
/**
* Initialize the registry.
*/
public function __construct() {
$this->init_abilities();
}
/**
* Initialize all WooCommerce abilities.
*/
private function init_abilities(): void {
AbilitiesCategories::init();
AbilitiesRestBridge::init();
}
/**
* Get all ability IDs from the WordPress Abilities API.
*
* @return array Array of all ability IDs.
*/
public function get_abilities_ids(): array {
// Check if the abilities API is available.
if ( ! function_exists( 'wp_get_abilities' ) ) {
return array();
}
$all_abilities = wp_get_abilities();
return array_keys( $all_abilities );
}
}

View File

@@ -0,0 +1,129 @@
<?php
/**
* Abilities REST Bridge class file.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities;
use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
defined( 'ABSPATH' ) || exit;
/**
* Abilities REST Bridge class for WooCommerce.
*
* Configuration-driven registry that exposes REST endpoints as WordPress abilities.
* Each ability is explicitly configured with ID, label, description, and operation.
*/
class AbilitiesRestBridge {
/**
* Get REST controller configurations with explicit IDs, labels, and descriptions.
*
* @return array Controller configurations.
*/
private static function get_configurations(): array {
return array(
array(
'controller' => \WC_REST_Products_Controller::class,
'route' => '/wc/v3/products',
'abilities' => array(
array(
'id' => 'woocommerce/products-list',
'operation' => 'list',
'label' => __( 'List Products', 'woocommerce' ),
'description' => __( 'Retrieve a paginated list of products with optional filters for status, category, price range, and other attributes.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/products-get',
'operation' => 'get',
'label' => __( 'Get Product', 'woocommerce' ),
'description' => __( 'Retrieve detailed information about a single product by ID, including price, description, images, and metadata.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/products-create',
'operation' => 'create',
'label' => __( 'Create Product', 'woocommerce' ),
'description' => __( 'Create a new product in WooCommerce with name, price, description, and other product attributes.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/products-update',
'operation' => 'update',
'label' => __( 'Update Product', 'woocommerce' ),
'description' => __( 'Update an existing product by modifying its attributes such as price, stock, description, or metadata.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/products-delete',
'operation' => 'delete',
'label' => __( 'Delete Product', 'woocommerce' ),
'description' => __( 'Permanently delete a product from the store. This action cannot be undone.', 'woocommerce' ),
),
),
),
array(
'controller' => \WC_REST_Orders_Controller::class,
'route' => '/wc/v3/orders',
'abilities' => array(
array(
'id' => 'woocommerce/orders-list',
'operation' => 'list',
'label' => __( 'List Orders', 'woocommerce' ),
'description' => __( 'Retrieve a paginated list of orders with optional filters for status, customer, date range, and other criteria.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/orders-get',
'operation' => 'get',
'label' => __( 'Get Order', 'woocommerce' ),
'description' => __( 'Retrieve detailed information about a single order by ID, including line items, customer details, and payment information.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/orders-create',
'operation' => 'create',
'label' => __( 'Create Order', 'woocommerce' ),
'description' => __( 'Create a new order with customer information, line items, shipping details, and payment information.', 'woocommerce' ),
),
array(
'id' => 'woocommerce/orders-update',
'operation' => 'update',
'label' => __( 'Update Order', 'woocommerce' ),
'description' => __( 'Update an existing order by modifying status, customer information, line items, or other order details.', 'woocommerce' ),
),
),
),
);
}
/**
* Initialize the ability registration.
*
* @internal
*/
final public static function init(): void {
/*
* Register abilities when Abilities API is ready.
* Support both old (pre-6.9) and new (6.9+) action names.
*/
add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
}
/**
* Register all configured abilities.
*/
public static function register_abilities(): void {
// Only register abilities if this is an MCP endpoint request.
// We check here (on abilities_api_init action) rather than earlier
// because REST request detection requires the WordPress REST infrastructure
// to be fully initialized.
if ( ! MCPAdapterProvider::is_mcp_request() ) {
return;
}
foreach ( self::get_configurations() as $config ) {
RestAbilityFactory::register_controller_abilities( $config );
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* REST Ability class file.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\REST;
defined( 'ABSPATH' ) || exit;
/**
* Custom WP_Ability subclass for REST API-based abilities.
*
* This class extends the base WP_Ability class but skips output validation
* to handle the discrepancies between WooCommerce REST API schemas and
* actual output. This is necessary because WooCommerce schemas are often
* incomplete or inaccurate regarding nullable fields and type variations.
*/
class RestAbility extends \WP_Ability {
/**
* Skip output validation for REST abilities.
*
* WooCommerce REST API schemas often don't accurately reflect the actual
* output, particularly for nullable fields and type variations. Rather than
* trying to fix all schema inconsistencies, we skip output validation for
* REST-based abilities while maintaining input validation and permissions.
*
* @param mixed $output The output to validate.
* @return true Always returns true (no validation).
*/
protected function validate_output( $output ) {
// Skip validation - trust that REST controllers return valid data.
return true;
}
}

View File

@@ -0,0 +1,353 @@
<?php
/**
* REST Ability Factory class file.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\REST;
use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;
defined( 'ABSPATH' ) || exit;
/**
* Factory class for creating abilities from REST controllers.
*
* Handles the conversion of WooCommerce REST API endpoints into WordPress abilities
* that can be consumed by MCP or other systems.
*/
class RestAbilityFactory {
/**
* Register abilities for a REST controller based on configuration.
*
* @param array $config Controller configuration containing controller class and abilities array.
*/
public static function register_controller_abilities( array $config ): void {
$controller_class = $config['controller'];
if ( ! class_exists( $controller_class ) ) {
return;
}
$controller = new $controller_class();
foreach ( $config['abilities'] as $ability_config ) {
self::register_single_ability( $controller, $ability_config, $config['route'] );
}
}
/**
* Register a single ability.
*
* @param object $controller REST controller instance.
* @param array $ability_config Ability configuration array.
* @param string $route REST route for this controller.
*/
private static function register_single_ability( $controller, array $ability_config, string $route ): void {
// Only proceed if wp_register_ability function exists.
if ( ! function_exists( 'wp_register_ability' ) ) {
return;
}
try {
$ability_args = array(
'label' => $ability_config['label'],
'description' => $ability_config['description'],
'category' => 'woocommerce-rest',
'input_schema' => self::get_schema_for_operation( $controller, $ability_config['operation'] ),
'output_schema' => self::get_output_schema( $controller, $ability_config['operation'] ),
'execute_callback' => function ( $input ) use ( $controller, $ability_config, $route ) {
return self::execute_operation( $controller, $ability_config['operation'], $input, $route );
},
'permission_callback' => function () use ( $controller, $ability_config ) {
return self::check_permission( $controller, $ability_config['operation'] );
},
'ability_class' => RestAbility::class,
'meta' => array(
'show_in_rest' => true,
),
);
// Add readonly annotation for GET operations (list and get).
if ( in_array( $ability_config['operation'], array( 'list', 'get' ), true ) ) {
$ability_args['meta']['annotations'] = array(
'readonly' => true,
);
}
wp_register_ability( $ability_config['id'], $ability_args );
} catch ( \Throwable $e ) {
// Log the error for debugging but don't break the registration of other abilities.
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error(
"Failed to register ability {$ability_config['id']}: " . $e->getMessage(),
array( 'source' => 'woocommerce-rest-abilities' )
);
}
}
}
/**
* Get input schema based on operation type.
*
* @param object $controller REST controller instance.
* @param string $operation Operation type (list, get, create, update, delete).
* @return array Input schema array.
*/
private static function get_schema_for_operation( $controller, string $operation ): array {
switch ( $operation ) {
case 'list':
// Use controller's collection parameters.
if ( method_exists( $controller, 'get_collection_params' ) ) {
return self::sanitize_args_to_schema( $controller->get_collection_params() );
}
break;
case 'create':
// Use controller's creatable schema.
if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
$args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE );
return self::sanitize_args_to_schema( $args );
}
break;
case 'update':
// Use controller's editable schema + ID.
if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
$args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE );
$schema = self::sanitize_args_to_schema( $args );
// Add ID field for update operations.
$schema['properties']['id'] = array(
'type' => 'integer',
'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
);
// Ensure ID is required.
if ( ! isset( $schema['required'] ) ) {
$schema['required'] = array();
}
if ( ! in_array( 'id', $schema['required'], true ) ) {
$schema['required'][] = 'id';
}
return $schema;
}
break;
case 'get':
case 'delete':
// Only need ID.
return array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
),
),
'required' => array( 'id' ),
);
}
// Fallback.
return array( 'type' => 'object' );
}
/**
* Sanitize WordPress REST args to valid JSON Schema format.
*
* Converts WordPress REST API argument arrays to JSON Schema by:
* - Removing PHP callbacks (sanitize_callback, validate_callback)
* - Converting 'required' from boolean-per-field to array-of-names
* - Removing WordPress-specific non-schema fields
* - Preserving valid JSON Schema properties
*
* @param array $args WordPress REST API arguments array.
* @return array Valid JSON Schema object.
*/
private static function sanitize_args_to_schema( array $args ): array {
$properties = array();
$required = array();
foreach ( $args as $key => $arg ) {
$property = array();
// Copy valid JSON Schema fields.
if ( isset( $arg['type'] ) ) {
$property['type'] = $arg['type'];
}
if ( isset( $arg['description'] ) ) {
$property['description'] = $arg['description'];
}
if ( isset( $arg['default'] ) ) {
$property['default'] = $arg['default'];
}
if ( isset( $arg['enum'] ) ) {
$property['enum'] = array_values( $arg['enum'] );
}
if ( isset( $arg['items'] ) ) {
$property['items'] = $arg['items'];
}
if ( isset( $arg['minimum'] ) ) {
$property['minimum'] = $arg['minimum'];
}
if ( isset( $arg['maximum'] ) ) {
$property['maximum'] = $arg['maximum'];
}
if ( isset( $arg['format'] ) ) {
$property['format'] = $arg['format'];
}
if ( isset( $arg['properties'] ) ) {
$property['properties'] = $arg['properties'];
}
// Convert readonly to readOnly (JSON Schema format).
if ( isset( $arg['readonly'] ) && $arg['readonly'] ) {
$property['readOnly'] = true;
}
// Collect required fields.
if ( isset( $arg['required'] ) && true === $arg['required'] ) {
$required[] = $key;
}
$properties[ $key ] = $property;
}
$schema = array(
'type' => 'object',
'properties' => $properties,
);
if ( ! empty( $required ) ) {
$schema['required'] = array_unique( $required );
}
return $schema;
}
/**
* Get output schema for operation.
*
* @param object $controller REST controller instance.
* @param string $operation Operation type.
* @return array Output schema array.
*/
private static function get_output_schema( $controller, string $operation ): array {
if ( method_exists( $controller, 'get_item_schema' ) ) {
$schema = $controller->get_item_schema();
if ( 'list' === $operation ) {
// For list operations, return object wrapping array of items.
// This ensures MCP compatibility while maintaining REST structure.
return array(
'type' => 'object',
'properties' => array(
'data' => array(
'type' => 'array',
'items' => $schema,
),
),
);
} elseif ( 'delete' === $operation ) {
// For delete operations, return simple confirmation.
return array(
'type' => 'object',
'properties' => array(
'deleted' => array( 'type' => 'boolean' ),
'previous' => $schema,
),
);
}
// For get, create, update operations.
return $schema;
}
return array( 'type' => 'object' );
}
/**
* Execute the REST operation.
*
* @param object $controller REST controller instance.
* @param string $operation Operation type.
* @param array $input Input parameters.
* @param string $route REST route for this controller.
* @return mixed Operation result.
*/
private static function execute_operation( $controller, string $operation, array $input, string $route ) {
$method = self::get_http_method_for_operation( $operation );
// Build final route - add ID for single item operations.
$request_route = $route;
if ( isset( $input['id'] ) && in_array( $operation, array( 'get', 'update', 'delete' ), true ) ) {
$request_route .= '/' . intval( $input['id'] );
unset( $input['id'] );
}
// Create REST request.
$request = new \WP_REST_Request( $method, $request_route );
foreach ( $input as $key => $value ) {
$request->set_param( $key, $value );
}
// Dispatch through REST API for proper validation and permissions.
$response = rest_do_request( $request );
if ( is_wp_error( $response ) ) {
return $response;
}
$data = $response instanceof \WP_REST_Response ? $response->get_data() : $response;
// For list operations, wrap in data object to match schema.
if ( 'list' === $operation ) {
return array( 'data' => $data );
}
return $data;
}
/**
* Get HTTP method for a given operation type.
*
* @param string $operation Operation type (list, get, create, update, delete).
* @return string HTTP method (GET, POST, PUT, DELETE).
*/
private static function get_http_method_for_operation( string $operation ): string {
$method_map = array(
'list' => 'GET',
'get' => 'GET',
'create' => 'POST',
'update' => 'PUT',
'delete' => 'DELETE',
);
return $method_map[ $operation ] ?? 'GET';
}
/**
* Check permissions for MCP operations.
*
* @param object $controller REST controller instance.
* @param string $operation Operation type.
* @return bool Whether permission is granted.
*/
private static function check_permission( $controller, string $operation ): bool {
// Get HTTP method for the operation.
$method = self::get_http_method_for_operation( $operation );
/**
* Filter to check REST ability permissions for HTTP method.
*
* @since 10.3.0
* @param bool $allowed Whether the operation is allowed. Default false.
* @param string $method HTTP method (GET, POST, PUT, DELETE).
* @param object $controller REST controller instance.
*/
return apply_filters( 'woocommerce_check_rest_ability_permissions_for_method', false, $method, $controller );
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* WooCommerce Abilities API Client (Namespaced Version)
*
* Simple interface for enabling WordPress Abilities API client scripts.
* This version uses WooCommerce's PSR-4 namespace structure.
*
* @package Automattic\WooCommerce\Internal\AbilitiesApi
* @version 10.4.0
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\AbilitiesApi;
/**
* AbilitiesClient class.
*/
class AbilitiesClient {
/**
* Whether the client has been enabled.
*
* @var bool
*/
private static bool $enabled = false;
/**
* Enable the WordPress Abilities API client for admin pages.
*
* This is the main method external plugins should use to enable
* the abilities API JavaScript client.
*
* @return bool True if successfully enabled, false otherwise.
*/
public static function enable(): bool {
// Only enable once.
if ( self::$enabled ) {
return true;
}
// Hook into admin_enqueue_scripts to enqueue when needed.
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_for_admin' ) );
self::$enabled = true;
return true;
}
/**
* Internal method to handle script enqueueing.
*/
public static function enqueue_for_admin(): void {
// Only enqueue on admin pages.
if ( ! is_admin() ) {
return;
}
// Enqueue the script if it's registered.
if ( wp_script_is( 'wp-abilities', 'registered' ) ) {
wp_enqueue_script( 'wp-abilities' );
}
}
}

View File

@@ -0,0 +1,308 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\AddressProvider;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Automattic\Jetpack\Constants;
use WC_Address_Provider;
/**
* Abstract Automattic address provider is an abstract implementation of the WC_Address_Provider that is meant to be used by Automattic services to get support for address autocomplete and maps with minimal code maintenance.
*
* @since 10.1.0
* @package WooCommerce
*/
abstract class AbstractAutomatticAddressProvider extends WC_Address_Provider {
/**
* The JWT for the address service.
*
* @var string
*/
private $jwt = null;
/**
* Loads up the JWT for the address service and saves it to transient.
*/
public function __construct() {
add_filter( 'pre_update_option_woocommerce_address_autocomplete_enabled', array( $this, 'refresh_cache' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'load_scripts' ) );
// Powered by Google branding.
$this->branding_html = 'Powered by&nbsp;<img style="height: 15px; width: 45px; margin-bottom: -2px;" src="' . plugins_url( '/assets/images/address-autocomplete/google.svg', WC_PLUGIN_FILE ) . '" alt="Google logo" />';
}
/**
* Get the JWT for the address service, a service should implement an A8C hosted API or some mechanism to get a JWT, this will be passed to frontend code to be used in the address autocomplete and maps.
*
* This method shouldn't implement any caching, it should only fetch the token or throw an exception, if you must handle caching, consider also overriding get_jwt.
*
* @return string The JWT for the address service.
*/
abstract public function get_address_service_jwt();
/**
* Get the telemetry status for the address service, this is meant to be overridden by the implementor to return true if the service has permission to send telemetry data.
*
* @return bool The telemetry status for the address service.
*/
public function can_telemetry() {
return false;
}
/**
* Loads up a JWT from cache or from the implementor side.
*
* @return void
*
* phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing -- As we wrap the throw in a try/catch.
*/
public function load_jwt() {
// If the address autocomplete is disabled, we don't load the JWT.
if ( wc_string_to_bool( get_option( 'woocommerce_address_autocomplete_enabled', 'no' ) ) !== true ) {
return;
}
// If we already have a loaded, valid token, we return early.
if ( $this->jwt && is_string( $this->jwt ) && JsonWebToken::shallow_validate( $this->jwt ) ) {
return;
}
$cached_jwt = $this->get_cached_option( 'address_autocomplete_jwt' );
// If we have a cached, valid token, we load it to class and return early.
if ( $cached_jwt && is_string( $cached_jwt ) && JsonWebToken::shallow_validate( $cached_jwt ) ) {
$this->jwt = $cached_jwt;
return;
}
$retry_data = $this->get_cached_option( 'jwt_retry_data' );
if ( $retry_data && isset( $retry_data['try_after'] ) && $retry_data['try_after'] > time() ) {
return;
}
try {
$fresh_jwt = $this->get_address_service_jwt();
if ( $fresh_jwt && is_string( $fresh_jwt ) && JsonWebToken::shallow_validate( $fresh_jwt ) ) {
$this->set_jwt( $fresh_jwt );
// Clear retry data on success.
$this->delete_cached_option( 'jwt_retry_data' );
return;
} else {
throw new \Exception( 'Invalid JWT received from address service.' );
}
} catch ( \Exception $e ) {
$retry_data['attempts'] = isset( $retry_data['attempts'] ) ? $retry_data['attempts'] + 1 : 1;
wc_get_logger()->error(
sprintf(
'Failed loading JWT for %1$s address autocomplete service (attempt %2$d) with error %3$s.',
$this->name,
$retry_data['attempts'],
$e->getMessage()
),
'address-autocomplete'
);
$backoff_hours = pow( 2, $retry_data['attempts'] - 1 ); // 1, 2, 4, 8 hours.
$retry_data['try_after'] = time() + ( $backoff_hours * HOUR_IN_SECONDS );
$this->update_cached_option( 'jwt_retry_data', $retry_data, DAY_IN_SECONDS );
}
}
/**
* Gets the JWT for the address service.
*
* @return string The JWT for the address service.
*/
public function get_jwt() {
if ( null === $this->jwt ) {
$this->load_jwt();
}
return $this->jwt;
}
/**
* Sets the JWT for the address service.
*
* @param string $jwt The JWT for the address service.
*/
public function set_jwt( $jwt ) {
$this->jwt = $jwt;
if ( null !== $jwt ) {
$cache_duration = $this->get_jwt_cache_duration( $jwt );
// If the token is expired, we don't cache it and we fetch a new one.
if ( 0 === $cache_duration ) {
$this->jwt = null;
$this->load_jwt();
return;
}
$this->update_cached_option( 'address_autocomplete_jwt', $jwt, $cache_duration );
} else {
$this->delete_cached_option( 'address_autocomplete_jwt' );
}
}
/**
* Gets the cache duration for the JWT.
*
* @param string $jwt The JWT for the address service.
* @return int The cache duration for the JWT.
*/
public function get_jwt_cache_duration( $jwt ) {
$parts = JsonWebToken::get_parts( $jwt );
if ( property_exists( $parts->payload, 'exp' ) ) {
return max( $parts->payload->exp - time(), 0 );
}
}
/**
* Deletes the cached token if we disable the autocomplete service or fetches a new one if it's enabled.
*
* @param string $setting If the service is enabled or disabled.
* @return string the setting value.
*/
public function refresh_cache( $setting ) {
if ( wc_string_to_bool( $setting ) ) {
$this->load_jwt();
} else {
$this->set_jwt( null );
}
return $setting;
}
/**
* Gets the cached option.
*
* @param string $key The key of the option.
* @return mixed|null The cached option.
*/
private function get_cached_option( $key ) {
$data = get_option( $this->id . '_' . $key );
if ( is_array( $data ) && isset( $data['data'] ) ) {
if ( ! self::is_expired( $data ) ) {
return $data['data'];
}
$this->delete_cached_option( $key );
}
return null;
}
/**
* Updates the cached option.
*
* @param string $key The key of the option.
* @param mixed $value The value of the option.
* @param int $ttl The TTL of the option.
*/
private function update_cached_option( $key, $value, $ttl = DAY_IN_SECONDS ) {
$result = update_option(
$this->id . '_' . $key,
array(
'data' => $value,
'updated' => time(),
'ttl' => $ttl,
),
false
);
if ( false === $result ) {
wp_cache_delete( $this->id . '_' . $key, 'options' );
}
}
/**
* Deletes the cached option.
*
* @param string $key The key of the option.
*/
private function delete_cached_option( $key ) {
if ( delete_option( $this->id . '_' . $key ) ) {
wp_cache_delete( $this->id . '_' . $key, 'options' );
}
}
/**
* Checks if the cache value is expired.
*
* @param array $cache_contents The cache contents.
*
* @return boolean True if the contents are expired. False otherwise.
*/
private static function is_expired( $cache_contents ) {
if ( ! is_array( $cache_contents ) || ! isset( $cache_contents['updated'] ) || ! isset( $cache_contents['ttl'] ) ) {
// Treat bad/invalid cache contents as expired.
return true;
}
// Double-check that we have integers for `updated` and `ttl`.
if ( ! is_int( $cache_contents['updated'] ) || ! is_int( $cache_contents['ttl'] ) ) {
return true;
}
$expires = $cache_contents['updated'] + $cache_contents['ttl'];
$now = time();
return $expires < $now;
}
/**
* Return asset URL, copied from WC_Frontend_Scripts::get_asset_url.
*
* @param string $path Assets path.
* @return string
*/
public static function get_asset_url( $path ) {
/**
* Filters the asset URL.
*
* @since 3.2.0
*
* @param string $url The asset URL.
* @param string $path The asset path.
* @return string The filtered asset URL.
*/
return apply_filters( 'woocommerce_get_asset_url', plugins_url( $path, Constants::get_constant( 'WC_PLUGIN_FILE' ) ), $path );
}
/**
* Enqueues the checkout script, checks if it's already registered or not so we don't duplicate, and prints out the JWT to the page to be consumed.
*/
public function load_scripts() {
// If the address autocomplete setting is disabled, don't load the scripts.
if ( wc_string_to_bool( get_option( 'woocommerce_address_autocomplete_enabled', 'no' ) ) !== true ) {
return;
}
if ( ! is_checkout() ) {
return;
}
if ( ! $this->get_jwt() ) {
return;
}
$suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
$version = Constants::get_constant( 'WC_VERSION' );
if ( ! wp_script_is( 'a8c-address-autocomplete-service', 'registered' ) ) {
wp_register_script( 'a8c-address-autocomplete-service', self::get_asset_url( 'assets/js/frontend/a8c-address-autocomplete-service' . $suffix . '.js' ), array( 'wc-address-autocomplete' ), $version, array( 'strategy' => 'defer' ) );
}
if ( ! wp_script_is( 'a8c-address-autocomplete-service', 'enqueued' ) ) {
wp_enqueue_script( 'a8c-address-autocomplete-service' );
}
wp_add_inline_script(
'a8c-address-autocomplete-service',
sprintf(
'var a8cAddressAutocompleteServiceKeys = a8cAddressAutocompleteServiceKeys || {}; a8cAddressAutocompleteServiceKeys[ %1$s ] = { key: %2$s, canTelemetry: %3$s };',
wp_json_encode( $this->id, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $this->get_jwt(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( false !== $this->can_telemetry() && (bool) $this->can_telemetry(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
),
'before'
);
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\AddressProvider;
use WC_Address_Provider;
/**
* Service class for managing address providers.
*/
class AddressProviderController {
/**
* Registered provider instances.
*
* @var WC_Address_Provider[]
*/
private $providers = array();
/**
* Preferred provider from options.
*
* @var string ID of preferred address provider.
*/
private $preferred_provider_option = '';
/**
* Constructor.
*
* @internal
*/
public function __construct() {
add_action( 'init', array( $this, 'init' ) );
}
/**
* Init function runs after this provider was added to DI container.
*
* @internal
*/
final public function init() {
$this->preferred_provider_option = get_option( 'woocommerce_address_autocomplete_provider', '' );
$this->providers = $this->get_registered_providers();
}
/**
* Get the registered providers.
*
* @return WC_Address_Provider[] array of WC_Address_Providers.
*/
public function get_providers(): array {
return $this->providers;
}
/**
* Get all registered providers.
*
* @return WC_Address_Provider[] array of WC_Address_Providers.
*/
private function get_registered_providers(): array {
/**
* Filter the registered address providers.
*
* @since 9.9.0
* @param array $providers Array of fully qualified class names (strings) or WC_Address_Provider instances.
* Class names will be instantiated automatically.
* Example: array( 'My_Provider_Class', new My_Other_Provider() )
*/
$provider_items = apply_filters( 'woocommerce_address_providers', array() );
// The filter returned nothing but an empty array, so we can skip the rest of the function.
if ( empty( $provider_items ) && is_array( $provider_items ) ) {
return array();
}
$logger = wc_get_logger();
if ( ! is_array( $provider_items ) ) {
$logger->error(
'Invalid return value for woocommerce_address_providers, expected an array of class names or instances.',
array(
'context' => 'address_provider_service',
)
);
return array();
}
$providers = array();
$seen_ids = array();
foreach ( $provider_items as $provider_item ) {
if ( is_string( $provider_item ) && class_exists( $provider_item ) ) {
$provider_item = new $provider_item();
}
// Providers need to be valid and extend WC_Address_Provider.
if ( ! is_a( $provider_item, WC_Address_Provider::class ) ) {
$logger->error(
sprintf(
'Invalid address provider item "%s", expected a string class name or WC_Address_Provider instance.',
is_object( $provider_item ) ? get_class( $provider_item ) : gettype( $provider_item )
),
array(
'context' => 'address_provider_service',
)
);
continue;
}
// Validate the instance has the necessary properties.
if ( empty( $provider_item->id ) || empty( $provider_item->name ) ) {
$logger->error(
'Invalid address provider instance, id or name property is missing or empty: ' . get_class( $provider_item ),
array(
'context' => 'address_provider_service',
)
);
continue;
}
// Check for duplicate IDs.
if ( isset( $seen_ids[ $provider_item->id ] ) ) {
$logger->error(
sprintf(
'Duplicate provider ID found. ID "%s" is used by both %s and %s.',
$provider_item->id,
$seen_ids[ $provider_item->id ],
get_class( $provider_item )
),
array(
'context' => 'address_provider_service',
)
);
continue;
}
// Track the ID and its provider class for error reporting.
$seen_ids[ $provider_item->id ] = get_class( $provider_item );
// Add the provider instance to the array after all checks are completed.
$providers[] = $provider_item;
}
if ( ! empty( $this->preferred_provider_option ) && ! empty( $providers ) ) {
// Look for the preferred provider in the array.
foreach ( $providers as $key => $provider ) {
if ( $provider->id === $this->preferred_provider_option ) {
// Found the preferred provider, move it to the beginning of the array.
$preferred_provider = $provider;
unset( $providers[ $key ] );
array_unshift( $providers, $preferred_provider );
break;
}
}
}
return $providers;
}
/**
* Check if a specific provider is registered and available.
*
* @param string $provider_id The provider ID to check.
* @return bool
*/
public function is_provider_available( string $provider_id ): bool {
foreach ( $this->providers as $provider ) {
if ( $provider->id === $provider_id ) {
return true;
}
}
return false;
}
/**
* Get the preferred provider; this is what was selected in the WooCommerce "preferred provider" setting *or* the
* first registered provider if no preference was set. If the provider selected in WC Settings is not registered
* anymore, it will fall back to the first registered provider. Any other case will return an empty string.
*
* @return string
*/
public function get_preferred_provider(): string {
if ( $this->is_provider_available( $this->preferred_provider_option ) ) {
return $this->preferred_provider_option;
}
// Get the first provider's ID.
return $this->providers[0]->id ?? '';
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* WooCommerce Activity Panel.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Contains backend logic for the activity panel feature.
*/
class ActivityPanels {
/**
* Class instance.
*
* @var ActivityPanels instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
// Run after Automattic\WooCommerce\Internal\Admin\Loader.
add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
}
/**
* Adds fields so that we can store activity panel last read and open times.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'activity_panel_inbox_last_read',
'activity_panel_reviews_last_read',
)
);
}
/**
* Add alert count to the component settings.
*
* @param array $settings Component settings.
*/
public function component_settings( $settings ) {
$settings['alertCount'] = Notes::get_notes_count( array( 'error', 'update' ), array( 'unactioned' ) );
return $settings;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Admin\Agentic;
/**
* Agentic Commerce Integration class
*
* Registers the Agentic Commerce Protocol as a WooCommerce integration.
* Manages settings for various AI agent providers (OpenAI, Anthropic, etc.)
*
* @since 10.4.0
*/
class AgenticCommerceIntegration extends \WC_Integration {
/**
* Settings page instance.
*
* @var AgenticSettingsPage
*/
private $settings_page;
/**
* Constructor.
*/
public function __construct() {
$this->id = 'agentic_commerce';
$this->method_title = __( 'Agentic Commerce', 'woocommerce' );
$this->method_description = __( 'Configure settings to allow AI agents to purchase from your store.', 'woocommerce' );
// Initialize settings page helper.
$this->settings_page = new AgenticSettingsPage();
// Bind to the save action for the settings.
add_action( 'woocommerce_update_options_integration_' . $this->id, array( $this, 'process_admin_options' ) );
}
/**
* Admin options output.
*/
public function admin_options() {
$settings = $this->settings_page->get_settings( array(), $this->id );
\WC_Admin_Settings::output_fields( $settings );
}
/**
* Process and save options.
*/
public function process_admin_options() {
// Let AgenticSettingsPage handle saving.
$this->settings_page->save_settings();
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Admin\Agentic;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
/**
* AgenticController class
*
* Main controller for Agentic Commerce Protocol features.
* Manages initialization of webhooks and future settings for the Agentic feature.
*
* @since 10.3.0
*/
class AgenticController implements RegisterHooksInterface {
/**
* Register this class instance to the appropriate hooks.
*
* @internal
*/
public function register() {
// Don't register hooks during installation.
if ( Constants::is_true( 'WC_INSTALLING' ) ) {
return;
}
// We want to run on init for translations but before woocommerce_init so that
// we can hook the new integration settings page. We should be able to simplify
// this by just hooking here when we no longer need to check if the feature is enabled.
add_action( 'before_woocommerce_init', array( $this, 'on_init' ) );
}
/**
* Hook into WordPress on init.
*
* @internal
*/
public function on_init() {
// Bail if the feature is not enabled.
if ( ! FeaturesUtil::feature_is_enabled( 'agentic_checkout' ) ) {
return;
}
// Resolve webhook manager from container.
wc_get_container()->get( AgenticWebhookManager::class )->register();
// Register Agentic Commerce integration.
add_filter( 'woocommerce_integrations', array( $this, 'add_agentic_commerce_integration' ) );
}
/**
* Add Agentic Commerce integration to WooCommerce integrations.
*
* @param array $integrations Existing integrations.
* @return array Modified integrations.
*/
public function add_agentic_commerce_integration( $integrations ): array {
if ( ! is_array( $integrations ) ) {
$integrations = array();
}
$integrations[] = AgenticCommerceIntegration::class;
return $integrations;
}
}

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Admin\Agentic;
/**
* AgenticSettingsPage class
*
* Adds Agentic Commerce settings to WooCommerce > Settings > Integration.
* Uses a provider-based system to allow multiple AI agent integrations.
*
* @since 10.4.0
*/
class AgenticSettingsPage {
/**
* Registry option name.
*/
const REGISTRY_OPTION = 'woocommerce_agentic_agent_registry';
/**
* Constructor.
*/
public function __construct() {
// No hooks needed - used by AgenticCommerceIntegration class.
}
/**
* Get the agent registry with default values.
*
* @return array Agent registry.
*/
private function get_registry() {
return get_option( self::REGISTRY_OPTION, array() );
}
/**
* Get registered providers.
*
* Each provider should return an array with:
* - id: string (unique identifier, e.g., 'openai')
* - name: string (display name, e.g., 'OpenAI')
* - description: string (optional description)
* - fields: array (settings fields configuration)
*
* @return array Array of registered providers.
*/
private function get_providers() {
$registry = $this->get_registry();
// Register built-in OpenAI provider.
$providers = array(
array(
'id' => 'openai',
'name' => __( 'ChatGPT', 'woocommerce' ),
'description' => sprintf(
/* translators: %s: URL to ChatGPT merchants application page */
__( 'To get started, <a href="%s" target="_blank">apply to ChatGPT</a>. Once approved, ChatGPT will provide the credentials below.', 'woocommerce' ),
'https://chatgpt.com/merchants'
),
'fields' => $this->get_openai_fields(),
),
);
/**
* Filter to register additional AI agent providers.
*
* Allows extensions to add their own AI agent provider settings.
* Each provider should return an array with id, name, description, and fields.
*
* @since 10.4.0
*
* @internal This filter is experimental and behind a non-visible feature flag. Backwards compatibility not guaranted.
*
* @param array $providers Array of provider configurations.
* @param array $registry Current registry data.
*/
$providers = apply_filters( 'woocommerce_agentic_commerce_providers', $providers, $registry );
// Validate provider structure.
$validated = array();
foreach ( $providers as $provider ) {
if (
! is_array( $provider )
|| empty( $provider['id'] )
|| empty( $provider['name'] )
|| ! is_array( $provider['fields'] ?? null )
) {
continue;
}
// Sanitize text fields.
$provider['id'] = sanitize_key( $provider['id'] );
$provider['name'] = sanitize_text_field( $provider['name'] );
if ( ! empty( $provider['description'] ) ) {
$provider['description'] = wp_kses_post( $provider['description'] );
}
$validated[] = $provider;
}
return $validated;
}
/**
* Get general Agentic Commerce settings.
*
* @param array $config Current general configuration.
* @return array Settings fields.
*/
private function get_general_settings( $config ) {
return array(
array(
'title' => __( 'Agentic commerce', 'woocommerce' ),
'type' => 'title',
'desc' => '',
'id' => 'agentic_commerce_general_settings',
),
array(
'title' => __( 'Enable product visibility', 'woocommerce' ),
'desc' => __( 'Allow products to be visible by default to the AI agents you integrate with. Can be overridden per product.', 'woocommerce' ),
'id' => 'woocommerce_agentic_enable_products_default',
'type' => 'checkbox',
'default' => ( ! empty( $config['enable_products_default'] ) && 'yes' === $config['enable_products_default'] ) ? 'yes' : 'no',
),
array(
'type' => 'sectionend',
'id' => 'agentic_commerce_general_settings',
),
);
}
/**
* Get store policies settings.
*
* @return array Settings fields.
*/
private function get_store_policies_settings() {
// Get URLs from WooCommerce/WordPress settings.
$terms_page_id = wc_terms_and_conditions_page_id();
$privacy_page_id = get_option( 'wp_page_for_privacy_policy' );
$terms_url = $terms_page_id ? get_permalink( $terms_page_id ) : '';
$privacy_url = $privacy_page_id ? get_permalink( $privacy_page_id ) : '';
// Build admin URLs for configuration links.
$advanced_settings_url = admin_url( 'admin.php?page=wc-settings&tab=advanced' );
$privacy_settings_url = admin_url( 'options-privacy.php' );
return array(
array(
'title' => __( 'Store policies', 'woocommerce' ),
'type' => 'title',
'desc' => '',
'id' => 'agentic_commerce_store_policies',
),
array(
'title' => __( 'Privacy Policy URL', 'woocommerce' ),
'desc' => sprintf(
/* translators: %s: URL to WordPress privacy settings */
__( 'Configure your Privacy Policy page in <a href="%s">Settings &gt; Privacy</a>.', 'woocommerce' ),
esc_url( $privacy_settings_url )
),
'id' => 'woocommerce_agentic_privacy_url_display',
'type' => 'text',
'default' => esc_url( $privacy_url ),
'custom_attributes' => array(
'disabled' => 'disabled',
'readonly' => 'readonly',
),
),
array(
'title' => __( 'Terms and Conditions URL', 'woocommerce' ),
'desc' => sprintf(
/* translators: %s: URL to WooCommerce advanced settings */
__( 'Configure your Terms and Conditions page in <a href="%s">WooCommerce &gt; Settings &gt; Advanced &gt; Page setup</a>.', 'woocommerce' ),
esc_url( $advanced_settings_url )
),
'id' => 'woocommerce_agentic_terms_url_display',
'type' => 'text',
'default' => esc_url( $terms_url ),
'custom_attributes' => array(
'disabled' => 'disabled',
'readonly' => 'readonly',
),
),
array(
'type' => 'sectionend',
'id' => 'agentic_commerce_store_policies',
),
);
}
/**
* Get OpenAI provider fields.
*
* @return array Fields configuration.
*/
private function get_openai_fields() {
return array(
array(
'title' => __( 'Authorization Token', 'woocommerce' ),
'desc' => __( 'The bearer token that ChatGPT uses to authenticate checkout requests.', 'woocommerce' ),
'id' => 'woocommerce_agentic_openai_bearer_token',
'type' => 'password',
'default' => '',
),
);
}
/**
* Get settings for Agentic Commerce integration.
*
* @param array $settings Current settings.
* @param string $current_section Current section ID.
* @return array Settings array.
*/
public function get_settings( $settings, $current_section ) {
if ( 'agentic_commerce' !== $current_section ) {
return $settings;
}
$agentic_settings = array();
$registry = $this->get_registry();
// Add general Agentic Commerce settings section.
$agentic_settings = array_merge( $agentic_settings, $this->get_general_settings( $registry['general'] ?? array() ) );
// Build settings for each provider.
$providers = $this->get_providers();
foreach ( $providers as $provider ) {
// Provider section header.
$agentic_settings[] = array(
'title' => $provider['name'],
'type' => 'title',
'desc' => $provider['description'] ?? '',
'id' => 'agentic_commerce_' . $provider['id'] . '_settings',
);
// Add provider fields.
foreach ( $provider['fields'] as $field ) {
$agentic_settings[] = $field;
}
// Provider section end.
$agentic_settings[] = array(
'type' => 'sectionend',
'id' => 'agentic_commerce_' . $provider['id'] . '_settings',
);
}
// Add store policies section.
$agentic_settings = array_merge( $agentic_settings, $this->get_store_policies_settings() );
return $agentic_settings;
}
/**
* Save settings to registry structure.
*/
public function save_settings() {
check_admin_referer( 'woocommerce-settings' );
$registry = $this->get_registry();
// Update general settings.
$registry['general'] = array(
'enable_products_default' => isset( $_POST['woocommerce_agentic_enable_products_default'] ) && '1' === $_POST['woocommerce_agentic_enable_products_default']
? 'yes'
: 'no',
);
// Update OpenAI settings.
$new_token = isset( $_POST['woocommerce_agentic_openai_bearer_token'] )
? sanitize_text_field( wp_unslash( $_POST['woocommerce_agentic_openai_bearer_token'] ) )
: '';
// Only update if a new token was provided; otherwise keep existing.
if ( ! empty( $new_token ) ) {
$registry['openai']['bearer_token'] = wp_hash_password( $new_token );
} elseif ( ! isset( $registry['openai']['bearer_token'] ) ) {
$registry['openai']['bearer_token'] = '';
}
/**
* Filter registry before saving.
*
* Allows extensions to save their own agent provider settings.
* Extensions can access $_POST directly for their settings but MUST sanitize all input
* using appropriate WordPress sanitization functions (sanitize_text_field, esc_url_raw, etc.)
* and call wp_unslash() on POST data.
*
* @since 10.4.0
*
* @internal This filter is experimental and behind a non-visible feature flag. Backwards compatibility not guaranted.
*
* @param array $registry Registry data to save. Extensions should add their provider settings to this array.
*/
$registry = apply_filters( 'woocommerce_agentic_commerce_save_settings', $registry );
// Save registry (don't autoload to prevent performance issues).
update_option( self::REGISTRY_OPTION, $registry, false );
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Admin\Agentic;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\OrderMetaKey;
use WC_Order;
use WC_Webhook;
/**
* AgenticWebhookManager class
*
* Integrates Agentic Commerce Protocol webhooks with WooCommerce's native webhook system.
* Defines custom action topics and handles filtering/transformation for ACP compliance.
*
* @since 10.3.0
*/
class AgenticWebhookManager implements RegisterHooksInterface {
/**
* Action that will be triggered for webhooks.
*
* @var string
*/
const WEBHOOK_ACTION = 'woocommerce_agentic_order_changed';
/**
* Topic that will be used for webhooks.
*
* @var string
*/
const WEBHOOK_TOPIC = 'action.' . self::WEBHOOK_ACTION;
/**
* Meta key to store if the first event has been delivered.
*
* @var string
*/
const FIRST_EVENT_DELIVERED_META_KEY = '_acp_order_created_sent';
/**
* Payload builder instance.
*
* @var AgenticWebhookPayloadBuilder
*/
private $payload_builder;
/**
* Initializes dependencies and hooks.
*
* @internal
*
* @param AgenticWebhookPayloadBuilder $payload_builder Payload builder instance.
*/
final public function init( AgenticWebhookPayloadBuilder $payload_builder ) {
$this->payload_builder = $payload_builder;
}
/**
* Initialize hooks for webhook integration.
*
* @internal
*/
public function register() {
add_filter( 'woocommerce_webhook_topics', array( $this, 'register_webhook_topic_names' ) );
// Hook into order lifecycle events to fire our custom actions.
add_action( 'woocommerce_new_order', array( $this, 'handle_order_created' ), 999, 2 ); // Hook late to give a chance for other plugins to modify.
add_action( 'woocommerce_order_status_changed', array( $this, 'handle_order_status_changed' ), 10, 4 );
add_action( 'woocommerce_order_refunded', array( $this, 'handle_order_refunded' ), 10, 1 );
// Customize webhook payload for our topics.
add_filter( 'woocommerce_webhook_payload', array( $this, 'customize_webhook_payload' ), 10, 4 );
// Customize webhook HTTP arguments for our topics.
add_filter( 'woocommerce_webhook_http_args', array( $this, 'customize_webhook_http_args' ), 10, 3 );
// When the webhook is delivered (or not), mark the first event as delivered.
add_action( 'woocommerce_webhook_delivery', array( $this, 'mark_first_event_delivered' ), 10, 5 );
}
/**
* Register webhook topic names for display in the UI.
*
* @param array $topics Existing topics.
* @return array Modified topics.
*/
public function register_webhook_topic_names( $topics ): array {
$topics[ self::WEBHOOK_TOPIC ] = __( 'Agentic Commerce Protocol: Order created or updated', 'woocommerce' );
return $topics;
}
/**
* Handle order creation.
*
* @param int $order_id Order ID.
* @param WC_Order $order Order object.
*/
public function handle_order_created( $order_id, $order ) {
if ( ! $this->should_trigger_webhook( $order ) ) {
return;
}
/**
* Fires when an Agentic order is updated or created.
*
* @since 10.3.0
*
* @param int $order_id Order ID.
* @param WC_Order $order Order object.
*/
do_action( self::WEBHOOK_ACTION, $order_id, $order );
}
/**
* Handle order status changes.
*
* @param int $order_id Order ID.
* @param string $old_status Old status.
* @param string $new_status New status.
* @param WC_Order $order Order object.
*/
public function handle_order_status_changed( $order_id, $old_status, $new_status, $order ) {
if ( ! $this->should_trigger_webhook( $order ) ) {
return;
}
/**
* Fires when an Agentic order status changes.
*
* @since 10.3.0
*
* @param int $order_id Order ID.
* @param WC_Order $order Order object.
*/
do_action( self::WEBHOOK_ACTION, $order_id, $order );
}
/**
* Handle order refunds.
*
* @param int $order_id Order ID.
*/
public function handle_order_refunded( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $this->should_trigger_webhook( $order ) ) {
return;
}
/**
* Fires when an Agentic order is refunded.
*
* @since 10.3.0
*
* @param int $order_id Order ID.
* @param WC_Order $order Order object.
*/
do_action( self::WEBHOOK_ACTION, $order_id, $order );
}
/**
* Check if webhook should be triggered for this order.
*
* @param WC_Order $order Order object.
* @return bool True if webhook should be triggered.
*/
private function should_trigger_webhook( $order ) {
// Only trigger for orders with an Agentic checkout session ID.
$checkout_session_id = $order->get_meta( OrderMetaKey::AGENTIC_CHECKOUT_SESSION_ID );
if ( empty( $checkout_session_id ) ) {
return false;
}
// Don't trigger for draft orders.
if (
in_array(
$order->get_status(),
array(
OrderStatus::CHECKOUT_DRAFT,
OrderStatus::DRAFT,
OrderStatus::AUTO_DRAFT,
),
true
)
) {
return false;
}
return true;
}
/**
* Customize webhook payload for Agentic topics.
*
* @param array $payload Original payload.
* @param string $resource_type Resource type.
* @param int $resource_id Resource ID.
* @param int $webhook_id Webhook ID.
* @return array Modified payload.
*/
public function customize_webhook_payload( $payload, $resource_type, $resource_id, $webhook_id ) {
$webhook = wc_get_webhook( $webhook_id );
if ( ! $webhook ) {
return $payload;
}
$topic = $webhook->get_topic();
// Check if this is one of our Agentic topics.
if ( self::WEBHOOK_TOPIC !== $topic ) {
return $payload;
}
// Get the order.
$order = wc_get_order( $resource_id );
if ( ! $order ) {
return $payload;
}
$is_first_event = 'sent' !== $order->get_meta( self::FIRST_EVENT_DELIVERED_META_KEY );
$event = $is_first_event ? 'order_create' : 'order_update';
// Build ACP-compliant payload.
return $this->payload_builder->build_payload( $event, $order );
}
/**
* Customize webhook HTTP arguments for Agentic topics.
*
* @param array $http_args HTTP arguments.
* @param mixed $arg First hook argument.
* @param int $webhook_id Webhook ID.
* @return array Modified HTTP arguments.
*/
public function customize_webhook_http_args( $http_args, $arg, $webhook_id ) {
$webhook = wc_get_webhook( $webhook_id );
if ( ! $webhook ) {
return $http_args;
}
$topic = $webhook->get_topic();
// Check if this is one of our Agentic topics.
if ( self::WEBHOOK_TOPIC !== $topic ) {
return $http_args;
}
// Compute HMAC signature per ACP webhook spec using WooCommerce's built-in method.
// The signature must be computed over the raw request body.
if ( isset( $http_args['body'] ) && ! empty( $webhook->get_secret() ) ) {
// Use WooCommerce's signature generation to ensure consistency.
$signature = $webhook->generate_signature( $http_args['body'] );
// Add Merchant-Signature header per ACP webhook specification.
$http_args['headers']['Merchant-Signature'] = $signature;
}
return $http_args;
}
/**
* Mark first event as delivered on successful webhook delivery.
*
* @param array $http_args HTTP request args.
* @param mixed $response HTTP response.
* @param float $duration Request duration.
* @param int $arg First argument to the action (order_id).
* @param int $webhook_id Webhook ID.
*/
public function mark_first_event_delivered( $http_args, $response, $duration, $arg, $webhook_id ) {
// Only proceed for successful responses.
if ( is_wp_error( $response ) ) {
return;
}
$code = wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return;
}
// Verify this is our webhook topic.
$webhook = wc_get_webhook( $webhook_id );
if ( ! $webhook || self::WEBHOOK_TOPIC !== $webhook->get_topic() ) {
return;
}
// $arg contains the order_id from do_action( self::WEBHOOK_ACTION, $order_id, $order ).
$order = wc_get_order( $arg );
if ( ! $order ) {
return;
}
if ( 'sent' !== $order->get_meta( self::FIRST_EVENT_DELIVERED_META_KEY ) ) {
$order->update_meta_data( self::FIRST_EVENT_DELIVERED_META_KEY, 'sent' );
$order->save();
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Admin\Agentic;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\OrderMetaKey;
use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\OrderStatus as ACPOrderStatus;
use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\RefundType;
use WC_Logger_Interface;
use WC_Order;
use WC_Order_Refund;
/**
* AgenticWebhookPayloadBuilder class
*
* Builds webhook payloads for the Agentic Commerce Protocol following
* the specification for order lifecycle events.
*
* @since 10.3.0
*/
class AgenticWebhookPayloadBuilder {
/**
* Money formatter instance.
*
* @var MoneyFormatter
*/
private $money_formatter;
/**
* Dependency initialization.
*
* @internal
*/
final public function init() {
$this->money_formatter = new MoneyFormatter();
}
/**
* Build the webhook payload for an order event.
*
* @param string $event Event type ('order_create' or 'order_update').
* @param WC_Order $order Order object.
* @return array Webhook payload.
*/
public function build_payload( string $event, WC_Order $order ): array {
return array(
'type' => $event,
'data' => $this->build_order_data( $order ),
);
}
/**
* Build the order data for the webhook payload.
*
* @param WC_Order $order Order object.
* @return array Order data.
*/
private function build_order_data( WC_Order $order ): array {
return array(
'type' => 'order',
'checkout_session_id' => $order->get_meta( OrderMetaKey::AGENTIC_CHECKOUT_SESSION_ID ),
'permalink_url' => $order->get_checkout_order_received_url(),
'status' => $this->map_order_status( $order->get_status() ),
'refunds' => $this->build_refunds_data( $order ),
);
}
/**
* Map WooCommerce order status to ACP status.
*
* ACP statuses: created, manual_review, confirmed, canceled, shipped, fulfilled
*
* @param string $wc_status WooCommerce order status.
* @return string ACP status.
*/
private function map_order_status( string $wc_status ): string {
$status_map = array(
// WooCommerce status => ACP status.
OrderStatus::PENDING => ACPOrderStatus::CREATED,
OrderStatus::PROCESSING => ACPOrderStatus::CONFIRMED,
OrderStatus::ON_HOLD => ACPOrderStatus::MANUAL_REVIEW,
OrderStatus::COMPLETED => ACPOrderStatus::FULFILLED,
OrderStatus::CANCELLED => ACPOrderStatus::CANCELED,
OrderStatus::REFUNDED => ACPOrderStatus::FULFILLED, // Refunded orders are still fulfilled.
OrderStatus::FAILED => ACPOrderStatus::CANCELED,
);
/**
* Filter the WooCommerce to ACP order status mapping.
*
* Allows extensions to map custom WooCommerce order statuses to ACP order statuses.
* The mapped status must be one of: created, manual_review, confirmed, canceled, shipped, fulfilled.
*
* @see Automattic\WooCommerce\Internal\Agentic\Enums\Specs\OrderStatus
*
* @since 10.3.0
*
* @param array $status_map Associative array of WooCommerce status => ACP status.
* @param string $wc_status The WooCommerce order status being mapped.
*/
$status_map = apply_filters( 'woocommerce_agentic_webhook_order_status_map', $status_map, $wc_status );
// Get mapped status or default to 'created'.
$mapped_status = isset( $status_map[ $wc_status ] ) ? $status_map[ $wc_status ] : ACPOrderStatus::CREATED;
// Validate the mapped status is a valid ACP status.
if ( ! ACPOrderStatus::is_valid( $mapped_status ) ) {
// Log a warning for invalid status but continue with fallback.
wc_get_logger()->warning(
sprintf(
'Invalid ACP order status "%s" returned by woocommerce_agentic_webhook_order_status_map filter for WooCommerce status "%s". Using "created" as fallback.',
$mapped_status,
$wc_status
),
array( 'source' => 'agentic-webhooks' )
);
return ACPOrderStatus::CREATED;
}
return $mapped_status;
}
/**
* Build refunds data for the order.
*
* @param WC_Order $order Order object.
* @return array Array of refunds.
*/
private function build_refunds_data( WC_Order $order ): array {
return array_map(
array( $this, 'build_single_refund_data' ),
$order->get_refunds()
);
}
/**
* Build data for a single refund.
*
* @param WC_Order_Refund $refund Refund object.
* @return array Refund data.
*/
private function build_single_refund_data( WC_Order_Refund $refund ): array {
$refund_type = $this->determine_refund_type( $refund );
$amount = abs( (float) $refund->get_total() ); // Get absolute value as refunds are negative.
// Convert amount to minor units using MoneyFormatter (respects store currency decimals).
$amount_in_minor_units = (int) $this->money_formatter->format( $amount );
return array(
'type' => $refund_type,
'amount' => $amount_in_minor_units,
);
}
/**
* Determine the refund type.
*
* @param WC_Order_Refund $refund Refund object.
* @return string Refund type ('store_credit' or 'original_payment').
*/
private function determine_refund_type( WC_Order_Refund $refund ): string {
// Default to original payment method.
$refund_type = RefundType::ORIGINAL_PAYMENT;
/**
* Filter the refund type for Agentic webhooks.
*
* This allows extensions to specify when a refund is store credit.
* By default, all refunds are assumed to be original payment method.
*
* @since 10.4.0
* @param string $refund_type The refund type ('store_credit' or 'original_payment').
* @param WC_Order_Refund $refund The refund object.
*/
return apply_filters( 'woocommerce_agentic_webhook_refund_type', $refund_type, $refund );
}
}

View File

@@ -0,0 +1,391 @@
<?php
/**
* WooCommerce Analytics.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\API\Reports\Cache;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* Contains backend logic for the Analytics feature.
*/
class Analytics {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_analytics_enabled';
/**
* Clear cache tool identifier.
*/
const CACHE_TOOL_ID = 'clear_woocommerce_analytics_cache';
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Determines if the feature has been toggled on or off.
*
* @var boolean
*/
protected static $is_updated = false;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
if ( ! Features::is_enabled( 'analytics' ) ) {
return;
}
add_filter( 'woocommerce_component_settings_preload_endpoints', array( $this, 'add_preload_endpoints' ) );
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_menu', array( $this, 'register_pages' ) );
add_filter( 'woocommerce_debug_tools', array( $this, 'register_cache_clear_tool' ) );
add_filter( 'woocommerce_debug_tools', array( $this, 'register_regenerate_order_fulfillment_status_tool' ), 12 );
}
/**
* Add the feature toggle to the features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
return $features;
}
/**
* Reloads the page when the option is toggled to make sure all Analytics features are loaded.
*
* @param string $old_value Old value.
* @param string $value New value.
*/
public static function reload_page_on_toggle( $old_value, $value ) {
if ( $old_value === $value ) {
return;
}
self::$is_updated = true;
}
/**
* Reload the page if the setting has been updated.
*/
public static function maybe_reload_page() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) {
return;
}
wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) );
exit();
}
/**
* Preload data from the countries endpoint.
*
* @param array $endpoints Array of preloaded endpoints.
* @return array
*/
public function add_preload_endpoints( $endpoints ) {
$screen_id = ( function_exists( 'get_current_screen' ) && get_current_screen() ) ? get_current_screen()->id : '';
// Only preload endpoints on wc-admin pages.
if ( 'woocommerce_page_wc-admin' === $screen_id ) {
$endpoints['performanceIndicators'] = '/wc-analytics/reports/performance-indicators/allowed';
$endpoints['leaderboards'] = '/wc-analytics/leaderboards/allowed';
}
return $endpoints;
}
/**
* Adds fields so that we can store user preferences for the columns to display on a report.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'categories_report_columns',
'coupons_report_columns',
'customers_report_columns',
'orders_report_columns',
'products_report_columns',
'revenue_report_columns',
'taxes_report_columns',
'variations_report_columns',
'dashboard_sections',
'dashboard_chart_type',
'dashboard_chart_interval',
'dashboard_leaderboard_rows',
'order_attribution_install_banner_dismissed',
'scheduled_updates_promotion_notice_dismissed',
)
);
}
/**
* Register the cache clearing tool on the WooCommerce > Status > Tools page.
*
* @param array $debug_tools Available debug tool registrations.
* @return array Filtered debug tool registrations.
*/
public function register_cache_clear_tool( $debug_tools ) {
$settings_url = add_query_arg(
array(
'page' => 'wc-admin',
'path' => '/analytics/settings',
),
get_admin_url( null, 'admin.php' )
);
$debug_tools[ self::CACHE_TOOL_ID ] = array(
'name' => __( 'Clear analytics cache', 'woocommerce' ),
'button' => __( 'Clear', 'woocommerce' ),
'desc' => sprintf(
/* translators: 1: opening link tag, 2: closing tag */
__( 'This tool will reset the cached values used in WooCommerce Analytics. If numbers still look off, try %1$sReimporting Historical Data%2$s.', 'woocommerce' ),
'<a href="' . esc_url( $settings_url ) . '">',
'</a>'
),
'callback' => array( $this, 'run_clear_cache_tool' ),
);
return $debug_tools;
}
/**
* Register the regenerate order fulfillment status tool on the WooCommerce > Status > Tools page.
*
* @param array $debug_tools Available debug tool registrations.
* @return array Filtered debug tool registrations.
*/
public function register_regenerate_order_fulfillment_status_tool( $debug_tools ) {
// Check if the fulfillments feature is enabled.
$container = wc_get_container();
$features_controller = $container->get( FeaturesController::class );
if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) {
return $debug_tools;
}
// If the order fulfillment status has already been regenerated, don't register the tool again.
if ( true === (bool) get_option( 'woocommerce_analytics_order_fulfillment_status_regenerated' ) ) {
return $debug_tools;
}
$debug_tools['regenerate_order_fulfillment_status'] = array(
'name' => __( 'Regenerate order fulfillment status for Analytics', 'woocommerce' ),
'button' => __( 'Regenerate', 'woocommerce' ),
'desc' => __( 'This tool will regenerate the order fulfillment status for all orders and update the Analytics data using a direct SQL query.', 'woocommerce' ),
'callback' => array( $this, 'run_regenerate_order_fulfillment_status_tool' ),
);
return $debug_tools;
}
/**
* Regenerate order fulfillment status directly using SQL.
*
* @return string Success message or error message.
*/
public function run_regenerate_order_fulfillment_status_tool() {
global $wpdb;
// Check if the column exists, create it if not.
if ( ! OrderStatsDataStore::has_fulfillment_status_column() ) {
$create_column_result = OrderStatsDataStore::add_fulfillment_status_column();
if ( true !== $create_column_result ) {
return sprintf(
/* translators: %s: error message */
__( 'Failed to create fulfillment status column: %s', 'woocommerce' ),
$create_column_result
);
}
}
$order_stats_table = $wpdb->prefix . 'wc_order_stats';
// If HPOS is enabled, use the wc_orders_meta table, else use wp_postmeta.
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
$order_meta_table = OrdersTableDataStore::get_meta_table_name();
$order_meta_column = 'order_id';
} else {
$order_meta_table = $wpdb->postmeta;
$order_meta_column = 'post_id';
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and column names cannot be prepared.
"UPDATE {$order_stats_table} os INNER JOIN {$order_meta_table} om ON os.order_id = om.{$order_meta_column}
SET os.fulfillment_status = CASE
WHEN om.meta_value = %s THEN NULL
ELSE om.meta_value
END
WHERE om.meta_key = %s",
'no_fulfillments',
'_fulfillment_status'
)
);
if ( false === $updated ) {
return __( 'Failed to update order fulfillment status. Please check the database logs for errors.', 'woocommerce' );
}
// Mark as completed.
update_option( 'woocommerce_analytics_order_fulfillment_status_regenerated', true, false );
return sprintf(
/* translators: %d: number of orders updated */
__( 'Successfully updated fulfillment status for %d orders.', 'woocommerce' ),
$updated
);
}
/**
* Registers report pages.
*/
public function register_pages() {
$report_pages = self::get_report_pages();
foreach ( $report_pages as $report_page ) {
if ( ! is_null( $report_page ) ) {
wc_admin_register_page( $report_page );
}
}
}
/**
* Get report pages.
*/
public static function get_report_pages() {
$overview_page = array(
'id' => 'woocommerce-analytics',
'title' => __( 'Analytics', 'woocommerce' ),
'path' => '/analytics/overview',
'icon' => 'dashicons-chart-bar',
'position' => 57, // After WooCommerce & Product menu items.
);
$report_pages = array(
$overview_page,
array(
'id' => 'woocommerce-analytics-overview',
'title' => __( 'Overview', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/overview',
),
array(
'id' => 'woocommerce-analytics-products',
'title' => __( 'Products', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/products',
),
array(
'id' => 'woocommerce-analytics-revenue',
'title' => __( 'Revenue', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/revenue',
),
array(
'id' => 'woocommerce-analytics-orders',
'title' => __( 'Orders', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/orders',
),
array(
'id' => 'woocommerce-analytics-variations',
'title' => __( 'Variations', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/variations',
),
array(
'id' => 'woocommerce-analytics-categories',
'title' => __( 'Categories', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/categories',
),
array(
'id' => 'woocommerce-analytics-coupons',
'title' => __( 'Coupons', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/coupons',
),
array(
'id' => 'woocommerce-analytics-taxes',
'title' => __( 'Taxes', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/taxes',
),
array(
'id' => 'woocommerce-analytics-downloads',
'title' => __( 'Downloads', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/downloads',
),
'yes' === get_option( 'woocommerce_manage_stock' ) ? array(
'id' => 'woocommerce-analytics-stock',
'title' => __( 'Stock', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/stock',
) : null,
array(
'id' => 'woocommerce-analytics-customers',
'title' => __( 'Customers', 'woocommerce' ),
'parent' => 'woocommerce',
'path' => '/customers',
),
array(
'id' => 'woocommerce-analytics-settings',
'title' => __( 'Settings', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/settings',
),
);
/**
* The analytics report items used in the menu.
*
* @since 6.4.0
*/
return apply_filters( 'woocommerce_analytics_report_menu_items', $report_pages );
}
/**
* "Clear" analytics cache by invalidating it.
*/
public function run_clear_cache_tool() {
Cache::invalidate();
return __( 'Analytics cache cleared.', 'woocommerce' );
}
}

View File

@@ -0,0 +1,338 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Block configuration used to specify blocks in BlockTemplate.
*/
class AbstractBlock implements BlockInterface {
use BlockFormattedTemplateTrait;
/**
* The block name.
*
* @var string
*/
private $name;
/**
* The block ID.
*
* @var string
*/
private $id;
/**
* The block order.
*
* @var int
*/
private $order = 10000;
/**
* The block attributes.
*
* @var array
*/
private $attributes = array();
/**
* The block hide conditions.
*
* @var array
*/
private $hide_conditions = array();
/**
* The block hide conditions counter.
*
* @var int
*/
private $hide_conditions_counter = 0;
/**
* The block disable conditions.
*
* @var array
*/
private $disable_conditions = array();
/**
* The block disable conditions counter.
*
* @var int
*/
private $disable_conditions_counter = 0;
/**
* The block template that this block belongs to.
*
* @var BlockTemplate
*/
private $root_template;
/**
* The parent container.
*
* @var ContainerInterface
*/
private $parent;
/**
* Block constructor.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param BlockContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
*/
public function __construct( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
$this->validate( $config, $root_template, $parent );
$this->root_template = $root_template;
$this->parent = is_null( $parent ) ? $root_template : $parent;
$this->name = $config[ self::NAME_KEY ];
if ( ! isset( $config[ self::ID_KEY ] ) ) {
$this->id = $this->root_template->generate_block_id( $this->get_name() );
} else {
$this->id = $config[ self::ID_KEY ];
}
if ( isset( $config[ self::ORDER_KEY ] ) ) {
$this->order = $config[ self::ORDER_KEY ];
}
if ( isset( $config[ self::ATTRIBUTES_KEY ] ) ) {
$this->attributes = $config[ self::ATTRIBUTES_KEY ];
}
if ( isset( $config[ self::HIDE_CONDITIONS_KEY ] ) ) {
foreach ( $config[ self::HIDE_CONDITIONS_KEY ] as $hide_condition ) {
$this->add_hide_condition( $hide_condition['expression'] );
}
}
if ( isset( $config[ self::DISABLE_CONDITIONS_KEY ] ) ) {
foreach ( $config[ self::DISABLE_CONDITIONS_KEY ] as $disable_condition ) {
$this->add_disable_condition( $disable_condition['expression'] );
}
}
}
/**
* Validate block configuration.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param ContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
*/
protected function validate( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
if ( isset( $parent ) && ( $parent->get_root_template() !== $root_template ) ) {
throw new \ValueError( 'The parent block must belong to the same template as the block.' );
}
if ( ! isset( $config[ self::NAME_KEY ] ) || ! is_string( $config[ self::NAME_KEY ] ) ) {
throw new \ValueError( 'The block name must be specified.' );
}
if ( isset( $config[ self::ORDER_KEY ] ) && ! is_int( $config[ self::ORDER_KEY ] ) ) {
throw new \ValueError( 'The block order must be an integer.' );
}
if ( isset( $config[ self::ATTRIBUTES_KEY ] ) && ! is_array( $config[ self::ATTRIBUTES_KEY ] ) ) {
throw new \ValueError( 'The block attributes must be an array.' );
}
}
/**
* Get the block name.
*/
public function get_name(): string {
return $this->name;
}
/**
* Get the block ID.
*/
public function get_id(): string {
return $this->id;
}
/**
* Get the block order.
*/
public function get_order(): int {
return $this->order;
}
/**
* Set the block order.
*
* @param int $order The block order.
*/
public function set_order( int $order ) {
$this->order = $order;
}
/**
* Get the block attributes.
*/
public function get_attributes(): array {
return $this->attributes;
}
/**
* Set the block attributes.
*
* @param array $attributes The block attributes.
*/
public function set_attributes( array $attributes ) {
$this->attributes = $attributes;
}
/**
* Set a block attribute value without replacing the entire attributes object.
*
* @param string $key The attribute key.
* @param mixed $value The attribute value.
*/
public function set_attribute( string $key, $value ) {
$this->attributes[ $key ] = $value;
}
/**
* Get the template that this block belongs to.
*/
public function &get_root_template(): BlockTemplateInterface {
return $this->root_template;
}
/**
* Get the parent block container.
*/
public function &get_parent(): ContainerInterface {
return $this->parent;
}
/**
* Remove the block from its parent.
*/
public function remove() {
$this->parent->remove_block( $this->id );
}
/**
* Check if the block is detached from its parent block container or the template it belongs to.
*
* @return bool True if the block is detached from its parent block container or the template it belongs to.
*/
public function is_detached(): bool {
$is_in_parent = $this->parent->get_block( $this->id ) === $this;
$is_in_root_template = $this->get_root_template()->get_block( $this->id ) === $this;
return ! ( $is_in_parent && $is_in_root_template );
}
/**
* Add a hide condition to the block.
*
* The hide condition is a JavaScript-like expression that will be evaluated on the client to determine if the block should be hidden.
* See [@woocommerce/expression-evaluation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/expression-evaluation/README.md) for more details.
*
* @param string $expression An expression, which if true, will hide the block.
*/
public function add_hide_condition( string $expression ): string {
$key = 'k' . $this->hide_conditions_counter;
$this->hide_conditions_counter++;
// Storing the expression in an array to allow for future expansion
// (such as adding the plugin that added the condition).
$this->hide_conditions[ $key ] = array(
'expression' => $expression,
);
/**
* Action called after a hide condition is added to a block.
*
* @param BlockInterface $block The block.
*
* @since 8.4.0
*/
do_action( 'woocommerce_block_template_after_add_hide_condition', $this );
return $key;
}
/**
* Remove a hide condition from the block.
*
* @param string $key The key of the hide condition to remove.
*/
public function remove_hide_condition( string $key ) {
unset( $this->hide_conditions[ $key ] );
/**
* Action called after a hide condition is removed from a block.
*
* @param BlockInterface $block The block.
*
* @since 8.4.0
*/
do_action( 'woocommerce_block_template_after_remove_hide_condition', $this );
}
/**
* Get the hide conditions of the block.
*/
public function get_hide_conditions(): array {
return $this->hide_conditions;
}
/**
* Add a disable condition to the block.
*
* The disable condition is a JavaScript-like expression that will be evaluated on the client to determine if the block should be hidden.
* See [@woocommerce/expression-evaluation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/expression-evaluation/README.md) for more details.
*
* @param string $expression An expression, which if true, will disable the block.
*/
public function add_disable_condition( string $expression ): string {
$key = 'k' . $this->disable_conditions_counter;
$this->disable_conditions_counter++;
// Storing the expression in an array to allow for future expansion
// (such as adding the plugin that added the condition).
$this->disable_conditions[ $key ] = array(
'expression' => $expression,
);
return $key;
}
/**
* Remove a disable condition from the block.
*
* @param string $key The key of the disable condition to remove.
*/
public function remove_disable_condition( string $key ) {
unset( $this->disable_conditions[ $key ] );
}
/**
* Get the disable conditions of the block.
*/
public function get_disable_conditions(): array {
return $this->disable_conditions;
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template class.
*/
abstract class AbstractBlockTemplate implements BlockTemplateInterface {
use BlockContainerTrait;
/**
* Get the template ID.
*/
abstract public function get_id(): string;
/**
* Get the template title.
*/
public function get_title(): string {
return '';
}
/**
* Get the template description.
*/
public function get_description(): string {
return '';
}
/**
* Get the template area.
*/
public function get_area(): string {
return 'uncategorized';
}
/**
* The block cache.
*
* @var BlockInterface[]
*/
private $block_cache = [];
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
return $this->block_cache[ $block_id ] ?? null;
}
/**
* Caches a block in the template. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's add_inner_block() method.
*
* @param BlockInterface $block The block to cache.
*
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \ValueError If the block template that the block belongs to is not this template.
*
* @ignore
*/
public function cache_block( BlockInterface &$block ) {
$id = $block->get_id();
if ( isset( $this->block_cache[ $id ] ) ) {
throw new \ValueError( 'A block with the specified ID already exists in the template.' );
}
if ( $block->get_root_template() !== $this ) {
throw new \ValueError( 'The block template that the block belongs to must be the same as this template.' );
}
$this->block_cache[ $id ] = $block;
}
/**
* Uncaches a block in the template. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's remove_block() method.
*
* @param string $block_id The block ID.
*
* @ignore
*/
public function uncache_block( string $block_id ) {
if ( isset( $this->block_cache[ $block_id ] ) ) {
unset( $this->block_cache[ $block_id ] );
}
}
/**
* Generate a block ID based on a base.
*
* @param string $id_base The base to use when generating an ID.
* @return string
*/
public function generate_block_id( string $id_base ): string {
$instance_count = 0;
do {
$instance_count++;
$block_id = $id_base . '-' . $instance_count;
} while ( isset( $this->block_cache[ $block_id ] ) );
return $block_id;
}
/**
* Get the root template.
*/
public function &get_root_template(): BlockTemplateInterface {
return $this;
}
/**
* Get the inner blocks as a formatted template.
*/
public function get_formatted_template(): array {
$inner_blocks = $this->get_inner_blocks_sorted_by_order();
$inner_blocks_formatted_template = array_map(
function( BlockInterface $block ) {
return $block->get_formatted_template();
},
$inner_blocks
);
return $inner_blocks_formatted_template;
}
/**
* Get the template as JSON like array.
*
* @return array The JSON.
*/
public function to_json(): array {
return array(
'id' => $this->get_id(),
'title' => $this->get_title(),
'description' => $this->get_description(),
'area' => $this->get_area(),
'blockTemplates' => $this->get_formatted_template(),
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Generic block with container properties to be used in BlockTemplate.
*/
class Block extends AbstractBlock implements BlockContainerInterface {
use BlockContainerTrait;
/**
* Add an inner block to this block.
*
* @param array $block_config The block data.
*/
public function &add_block( array $block_config ): BlockInterface {
$block = new Block( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Trait for block containers.
*/
trait BlockContainerTrait {
use BlockFormattedTemplateTrait {
get_formatted_template as get_block_formatted_template;
}
/**
* The inner blocks.
*
* @var BlockInterface[]
*/
private $inner_blocks = array();
// phpcs doesn't take into account exceptions thrown by called methods.
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Add a block to the block container.
*
* @param BlockInterface $block The block.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \UnexpectedValueException If the block container is not the parent of the block.
* @throws \UnexpectedValueException If the block container's root template is not the same as the block's root template.
*/
protected function &add_inner_block( BlockInterface $block ): BlockInterface {
if ( $block->get_parent() !== $this ) {
throw new \UnexpectedValueException( 'The block container is not the parent of the block.' );
}
if ( $block->get_root_template() !== $this->get_root_template() ) {
throw new \UnexpectedValueException( 'The block container\'s root template is not the same as the block\'s root template.' );
}
$is_detached = method_exists( $this, 'is_detached' ) && $this->is_detached();
if ( ! $is_detached ) {
$this->get_root_template()->cache_block( $block );
}
$this->inner_blocks[] = &$block;
$this->do_after_add_block_action( $block );
$this->do_after_add_specific_block_action( $block );
return $block;
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Checks if a block is a descendant of the block container.
*
* @param BlockInterface $block The block.
*/
private function is_block_descendant( BlockInterface $block ): bool {
$parent = $block->get_parent();
if ( $parent === $this ) {
return true;
}
if ( ! $parent instanceof BlockInterface ) {
return false;
}
return $this->is_block_descendant( $parent );
}
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
foreach ( $this->inner_blocks as $block ) {
if ( $block->get_id() === $block_id ) {
return $block;
}
}
foreach ( $this->inner_blocks as $block ) {
if ( $block instanceof ContainerInterface ) {
$block = $block->get_block( $block_id );
if ( $block ) {
return $block;
}
}
}
return null;
}
/**
* Remove a block from the block container.
*
* @param string $block_id The block ID.
*
* @throws \UnexpectedValueException If the block container is not an ancestor of the block.
*/
public function remove_block( string $block_id ) {
$root_template = $this->get_root_template();
$block = $root_template->get_block( $block_id );
if ( ! $block ) {
return;
}
if ( ! $this->is_block_descendant( $block ) ) {
throw new \UnexpectedValueException( 'The block container is not an ancestor of the block.' );
}
// If the block is a container, remove all of its blocks.
if ( $block instanceof ContainerInterface ) {
$block->remove_blocks();
}
$parent = $block->get_parent();
$parent->remove_inner_block( $block );
}
/**
* Remove all blocks from the block container.
*/
public function remove_blocks() {
array_map(
function ( BlockInterface $block ) {
$this->remove_block( $block->get_id() );
},
$this->inner_blocks
);
}
/**
* Remove a block from the block container's inner blocks. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's remove_block() method.
*
* @param BlockInterface $block The block.
*/
public function remove_inner_block( BlockInterface $block ) {
// Remove block from root template's cache.
$root_template = $this->get_root_template();
$root_template->uncache_block( $block->get_id() );
$this->inner_blocks = array_filter(
$this->inner_blocks,
function ( BlockInterface $inner_block ) use ( $block ) {
return $inner_block !== $block;
}
);
$this->do_after_remove_block_action( $block );
$this->do_after_remove_specific_block_action( $block );
}
/**
* Get the inner blocks sorted by order.
*/
private function get_inner_blocks_sorted_by_order(): array {
$sorted_inner_blocks = $this->inner_blocks;
usort(
$sorted_inner_blocks,
function( BlockInterface $a, BlockInterface $b ) {
return $a->get_order() <=> $b->get_order();
}
);
return $sorted_inner_blocks;
}
/**
* Get the inner blocks as a formatted template.
*/
public function get_formatted_template(): array {
$arr = $this->get_block_formatted_template();
$inner_blocks = $this->get_inner_blocks_sorted_by_order();
if ( ! empty( $inner_blocks ) ) {
$arr[] = array_map(
function( BlockInterface $block ) {
return $block->get_formatted_template();
},
$inner_blocks
);
}
return $arr;
}
/**
* Do the `woocommerce_block_template_after_add_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is added to a block container.
*
* This action can be used to perform actions after a block is added to the block container,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_add_block', $block );
} catch ( \Exception $e ) {
$this->do_after_add_block_error_action( $block, 'woocommerce_block_template_after_add_block', $e );
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_add_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is added to a template with a specific area.
*
* This action can be used to perform actions after a specific block is added to a template with a specific area,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->do_after_add_block_error_action( $block, "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $e );
}
}
/**
* Do the `woocommerce_block_after_add_block_error` action.
*
* @param BlockInterface $block The block.
* @param string $action The action that threw the exception.
* @param \Exception $e The exception.
*/
private function do_after_add_block_error_action( BlockInterface $block, string $action, \Exception $e ) {
/**
* Action called after an exception is thrown by a `woocommerce_block_template_after_add_block` action hook.
*
* @param BlockInterface $block The block.
* @param string $action The action that threw the exception.
* @param \Exception $exception The exception.
*
* @since 8.4.0
*/
do_action(
'woocommerce_block_template_after_add_block_error',
$block,
$action,
$e,
);
}
/**
* Do the `woocommerce_block_template_after_remove_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is removed from a block container.
*
* This action can be used to perform actions after a block is removed from the block container,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_remove_block', $block );
} catch ( \Exception $e ) {
$this->do_after_remove_block_error_action( $block, 'woocommerce_block_template_after_remove_block', $e );
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_remove_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is removed from a template with a specific area.
*
* This action can be used to perform actions after a specific block is removed from a template with a specific area,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->do_after_remove_block_error_action( $block, "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $e );
}
}
/**
* Do the `woocommerce_block_after_remove_block_error` action.
*
* @param BlockInterface $block The block.
* @param string $action The action that threw the exception.
* @param \Exception $e The exception.
*/
private function do_after_remove_block_error_action( BlockInterface $block, string $action, \Exception $e ) {
/**
* Action called after an exception is thrown by a `woocommerce_block_template_after_remove_block` action hook.
*
* @param BlockInterface $block The block.
* @param string $action The action that threw the exception.
* @param \Exception $exception The exception.
*
* @since 8.4.0
*/
do_action(
'woocommerce_block_template_after_remove_block_error',
$block,
$action,
$e,
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
/**
* Trait for block formatted template.
*/
trait BlockFormattedTemplateTrait {
/**
* Get the block configuration as a formatted template.
*
* @return array The block configuration as a formatted template.
*/
public function get_formatted_template(): array {
$arr = array(
$this->get_name(),
array_merge(
$this->get_attributes(),
array(
'_templateBlockId' => $this->get_id(),
'_templateBlockOrder' => $this->get_order(),
),
! empty( $this->get_hide_conditions() ) ? array(
'_templateBlockHideConditions' => $this->get_formatted_hide_conditions(),
) : array(),
! empty( $this->get_disable_conditions() ) ? array(
'_templateBlockDisableConditions' => $this->get_formatted_disable_conditions(),
) : array(),
),
);
return $arr;
}
/**
* Get the block hide conditions formatted for inclusion in a formatted template.
*/
private function get_formatted_hide_conditions(): array {
return $this->format_conditions( $this->get_hide_conditions() );
}
/**
* Get the block disable conditions formatted for inclusion in a formatted template.
*/
private function get_formatted_disable_conditions(): array {
return $this->format_conditions( $this->get_disable_conditions() );
}
/**
* Formats conditions in the expected format to include in the template.
*
* @param array $conditions The conditions to format.
*/
private function format_conditions( $conditions ): array {
$formatted_expressions = array_map(
function( $condition ) {
return array(
'expression' => $condition['expression'],
);
},
array_values( $conditions )
);
return $formatted_expressions;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template class.
*/
class BlockTemplate extends AbstractBlockTemplate {
/**
* Get the template ID.
*/
public function get_id(): string {
return 'woocommerce-block-template';
}
/**
* Add an inner block to this template.
*
* @param array $block_config The block data.
*/
public function add_block( array $block_config ): BlockInterface {
$block = new Block( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}

View File

@@ -0,0 +1,507 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Logger for block template modifications.
*/
class BlockTemplateLogger {
const BLOCK_ADDED = 'block_added';
const BLOCK_REMOVED = 'block_removed';
const BLOCK_MODIFIED = 'block_modified';
const BLOCK_ADDED_TO_DETACHED_CONTAINER = 'block_added_to_detached_container';
const HIDE_CONDITION_ADDED = 'hide_condition_added';
const HIDE_CONDITION_REMOVED = 'hide_condition_removed';
const HIDE_CONDITION_ADDED_TO_DETACHED_BLOCK = 'hide_condition_added_to_detached_block';
const ERROR_AFTER_BLOCK_ADDED = 'error_after_block_added';
const ERROR_AFTER_BLOCK_REMOVED = 'error_after_block_removed';
const LOG_HASH_TRANSIENT_BASE_NAME = 'wc_block_template_events_log_hash_';
/**
* Event types.
*
* @var array
*/
public static $event_types = array(
self::BLOCK_ADDED => array(
'level' => \WC_Log_Levels::DEBUG,
'message' => 'Block added to template.',
),
self::BLOCK_REMOVED => array(
'level' => \WC_Log_Levels::NOTICE,
'message' => 'Block removed from template.',
),
self::BLOCK_MODIFIED => array(
'level' => \WC_Log_Levels::NOTICE,
'message' => 'Block modified in template.',
),
self::BLOCK_ADDED_TO_DETACHED_CONTAINER => array(
'level' => \WC_Log_Levels::WARNING,
'message' => 'Block added to detached container. Block will not be included in the template, since the container will not be included in the template.',
),
self::HIDE_CONDITION_ADDED => array(
'level' => \WC_Log_Levels::NOTICE,
'message' => 'Hide condition added to block.',
),
self::HIDE_CONDITION_REMOVED => array(
'level' => \WC_Log_Levels::NOTICE,
'message' => 'Hide condition removed from block.',
),
self::HIDE_CONDITION_ADDED_TO_DETACHED_BLOCK => array(
'level' => \WC_Log_Levels::WARNING,
'message' => 'Hide condition added to detached block. Block will not be included in the template, so the hide condition is not needed.',
),
self::ERROR_AFTER_BLOCK_ADDED => array(
'level' => \WC_Log_Levels::WARNING,
'message' => 'Error after block added to template.',
),
self::ERROR_AFTER_BLOCK_REMOVED => array(
'level' => \WC_Log_Levels::WARNING,
'message' => 'Error after block removed from template.',
),
);
/**
* Singleton instance.
*
* @var BlockTemplateLogger
*/
protected static $instance = null;
/**
* Logger instance.
*
* @var \WC_Logger
*/
protected $logger = null;
/**
* All template events.
*
* @var array
*/
private $all_template_events = array();
/**
* Templates.
*
* @var array
*/
private $templates = array();
/**
* Threshold severity.
*
* @var int
*/
private $threshold_severity = null;
/**
* Get the singleton instance.
*/
public static function get_instance(): BlockTemplateLogger {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
protected function __construct() {
$this->logger = wc_get_logger();
$threshold = get_option( 'woocommerce_block_template_logging_threshold', \WC_Log_Levels::WARNING );
if ( ! \WC_Log_Levels::is_valid_level( $threshold ) ) {
$threshold = \WC_Log_Levels::INFO;
}
$this->threshold_severity = \WC_Log_Levels::get_level_severity( $threshold );
add_action(
'woocommerce_block_template_after_add_block',
function ( BlockInterface $block ) {
$is_detached = method_exists( $block->get_parent(), 'is_detached' ) && $block->get_parent()->is_detached();
$this->log(
$is_detached
? $this::BLOCK_ADDED_TO_DETACHED_CONTAINER
: $this::BLOCK_ADDED,
$block,
);
},
0,
);
add_action(
'woocommerce_block_template_after_remove_block',
function ( BlockInterface $block ) {
$this->log(
$this::BLOCK_REMOVED,
$block,
);
},
0,
);
add_action(
'woocommerce_block_template_after_add_hide_condition',
function ( BlockInterface $block ) {
$this->log(
$block->is_detached()
? $this::HIDE_CONDITION_ADDED_TO_DETACHED_BLOCK
: $this::HIDE_CONDITION_ADDED,
$block,
);
},
0
);
add_action(
'woocommerce_block_template_after_remove_hide_condition',
function ( BlockInterface $block ) {
$this->log(
$this::HIDE_CONDITION_REMOVED,
$block,
);
},
0
);
add_action(
'woocommerce_block_template_after_add_block_error',
function ( BlockInterface $block, string $action, \Exception $exception ) {
$this->log(
$this::ERROR_AFTER_BLOCK_ADDED,
$block,
array(
'action' => $action,
'exception' => $exception,
),
);
},
0,
3
);
add_action(
'woocommerce_block_template_after_remove_block_error',
function ( BlockInterface $block, string $action, \Exception $exception ) {
$this->log(
$this::ERROR_AFTER_BLOCK_REMOVED,
$block,
array(
'action' => $action,
'exception' => $exception,
),
);
},
0,
3
);
}
/**
* Get all template events for a given template as a JSON like array.
*
* @param string $template_id Template ID.
*/
public function template_events_to_json( string $template_id ): array {
if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
return array();
}
$template_events = $this->all_template_events[ $template_id ];
return $this->to_json( $template_events );
}
/**
* Get all template events as a JSON like array.
*
* @param array $template_events Template events.
*
* @return array The JSON.
*/
private function to_json( array $template_events ): array {
$json = array();
foreach ( $template_events as $template_event ) {
$container = $template_event['container'];
$block = $template_event['block'];
$json[] = array(
'level' => $template_event['level'],
'event_type' => $template_event['event_type'],
'message' => $template_event['message'],
'container' => $container instanceof BlockInterface
? array(
'id' => $container->get_id(),
'name' => $container->get_name(),
)
: null,
'block' => array(
'id' => $block->get_id(),
'name' => $block->get_name(),
),
'additional_info' => $this->format_info( $template_event['additional_info'] ),
);
}
return $json;
}
/**
* Log all template events for a given template to the log file.
*
* @param string $template_id Template ID.
*/
public function log_template_events_to_file( string $template_id ) {
if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
return;
}
$template_events = $this->all_template_events[ $template_id ];
$hash = $this->generate_template_events_hash( $template_events );
if ( ! $this->has_template_events_changed( $template_id, $hash ) ) {
// Nothing has changed since the last time this was logged,
// so don't log it again.
return;
}
$this->set_template_events_log_hash( $template_id, $hash );
$template = $this->templates[ $template_id ];
foreach ( $template_events as $template_event ) {
$info = array_merge(
array(
'template' => $template,
'container' => $template_event['container'],
'block' => $template_event['block'],
),
$template_event['additional_info']
);
$message = $this->format_message( $template_event['message'], $info );
$this->logger->log(
$template_event['level'],
$message,
array( 'source' => 'block_template' )
);
}
}
/**
* Has the template events changed since the last time they were logged?
*
* @param string $template_id Template ID.
* @param string $events_hash Events hash.
*/
private function has_template_events_changed( string $template_id, string $events_hash ) {
$previous_hash = get_transient( self::LOG_HASH_TRANSIENT_BASE_NAME . $template_id );
return $previous_hash !== $events_hash;
}
/**
* Generate a hash for a given set of template events.
*
* @param array $template_events Template events.
*/
private function generate_template_events_hash( array $template_events ): string {
return md5( wp_json_encode( $this->to_json( $template_events ) ) );
}
/**
* Set the template events hash for a given template.
*
* @param string $template_id Template ID.
* @param string $hash Hash of template events.
*/
private function set_template_events_log_hash( string $template_id, string $hash ) {
set_transient( self::LOG_HASH_TRANSIENT_BASE_NAME . $template_id, $hash );
}
/**
* Log an event.
*
* @param string $event_type Event type.
* @param BlockInterface $block Block.
* @param array $additional_info Additional info.
*/
private function log( string $event_type, BlockInterface $block, $additional_info = array() ) {
if ( ! isset( self::$event_types[ $event_type ] ) ) {
/* translators: 1: WC_Logger::log 2: level */
wc_doing_it_wrong( __METHOD__, sprintf( __( '%1$s was called with an invalid event type "%2$s".', 'woocommerce' ), '<code>BlockTemplateLogger::log</code>', $event_type ), '8.4' );
}
$event_type_info = isset( self::$event_types[ $event_type ] )
? array_merge(
self::$event_types[ $event_type ],
array(
'event_type' => $event_type,
)
)
: array(
'level' => \WC_Log_Levels::ERROR,
'event_type' => $event_type,
'message' => 'Unknown error.',
);
if ( ! $this->should_handle( $event_type_info['level'] ) ) {
return;
}
$template = $block->get_root_template();
$container = $block->get_parent();
$this->add_template_event( $event_type_info, $template, $container, $block, $additional_info );
}
/**
* Should the logger handle a given level?
*
* @param int $level Level to check.
*/
private function should_handle( $level ) {
return $this->threshold_severity <= \WC_Log_Levels::get_level_severity( $level );
}
/**
* Add a template event.
*
* @param array $event_type_info Event type info.
* @param BlockTemplateInterface $template Template.
* @param ContainerInterface $container Container.
* @param BlockInterface $block Block.
* @param array $additional_info Additional info.
*/
private function add_template_event( array $event_type_info, BlockTemplateInterface $template, ContainerInterface $container, BlockInterface $block, array $additional_info = array() ) {
$template_id = $template->get_id();
if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
$this->all_template_events[ $template_id ] = array();
$this->templates[ $template_id ] = $template;
}
$template_events = &$this->all_template_events[ $template_id ];
$template_events[] = array(
'level' => $event_type_info['level'],
'event_type' => $event_type_info['event_type'],
'message' => $event_type_info['message'],
'container' => $container,
'block' => $block,
'additional_info' => $additional_info,
);
}
/**
* Format a message for logging.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
private function format_message( string $message, array $info = array() ): string {
$formatted_message = sprintf(
"%s\n%s",
$message,
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
print_r( $this->format_info( $info ), true ),
);
return $formatted_message;
}
/**
* Format info for logging.
*
* @param array $info Info to log.
*/
private function format_info( array $info ): array {
$formatted_info = $info;
if ( isset( $info['exception'] ) && $info['exception'] instanceof \Exception ) {
$formatted_info['exception'] = $this->format_exception( $info['exception'] );
}
if ( isset( $info['container'] ) ) {
if ( $info['container'] instanceof BlockContainerInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
} elseif ( $info['container'] instanceof BlockTemplateInterface ) {
$formatted_info['container'] = $this->format_template( $info['container'] );
} elseif ( $info['container'] instanceof BlockInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
}
}
if ( isset( $info['block'] ) && $info['block'] instanceof BlockInterface ) {
$formatted_info['block'] = $this->format_block( $info['block'] );
}
if ( isset( $info['template'] ) && $info['template'] instanceof BlockTemplateInterface ) {
$formatted_info['template'] = $this->format_template( $info['template'] );
}
return $formatted_info;
}
/**
* Format an exception for logging.
*
* @param \Exception $exception Exception to format.
*/
private function format_exception( \Exception $exception ): array {
return array(
'message' => $exception->getMessage(),
'source' => "{$exception->getFile()}: {$exception->getLine()}",
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
'trace' => print_r( $this->format_exception_trace( $exception->getTrace() ), true ),
);
}
/**
* Format an exception trace for logging.
*
* @param array $trace Exception trace to format.
*/
private function format_exception_trace( array $trace ): array {
$formatted_trace = array();
foreach ( $trace as $source ) {
$formatted_trace[] = "{$source['file']}: {$source['line']}";
}
return $formatted_trace;
}
/**
* Format a block template for logging.
*
* @param BlockTemplateInterface $template Template to format.
*/
private function format_template( BlockTemplateInterface $template ): string {
return "{$template->get_id()} (area: {$template->get_area()})";
}
/**
* Format a block for logging.
*
* @param BlockInterface $block Block to format.
*/
private function format_block( BlockInterface $block ): string {
return "{$block->get_id()} (name: {$block->get_name()})";
}
}

View File

@@ -0,0 +1,309 @@
<?php
/**
* Keeps the product category lookup table in sync with live data.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* \Automattic\WooCommerce\Internal\Admin\CategoryLookup class.
*/
class CategoryLookup {
/**
* Stores changes to categories we need to sync.
*
* @var array
*/
protected $edited_product_cats = array();
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init hooks.
*/
public function init() {
add_action( 'generate_category_lookup_table', array( $this, 'regenerate' ) );
add_action( 'edit_product_cat', array( $this, 'before_edit' ), 99 );
add_action( 'edited_product_cat', array( $this, 'on_edit' ), 99 );
add_action( 'created_product_cat', array( $this, 'on_create' ), 99 );
add_action( 'init', array( $this, 'define_category_lookup_tables_in_wpdb' ) );
}
/**
* Regenerate all lookup table data.
*/
public function regenerate() {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE $wpdb->wc_category_lookup" );
$terms = get_terms(
'product_cat',
array(
'hide_empty' => false,
'fields' => 'id=>parent',
)
);
$hierarchy = array();
$inserts = array();
$this->unflatten_terms( $hierarchy, $terms, 0 );
$this->get_term_insert_values( $inserts, $hierarchy );
if ( ! $inserts ) {
return;
}
$insert_string = implode(
'),(',
array_map(
function( $item ) {
return implode( ',', $item );
},
$inserts
)
);
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_tree_id,category_id) VALUES ({$insert_string})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Store edits so we know when the parent ID changes.
*
* @param int $category_id Term ID being edited.
*/
public function before_edit( $category_id ) {
$category = get_term( $category_id, 'product_cat' );
$this->edited_product_cats[ $category_id ] = $category->parent;
}
/**
* When a product category gets edited, see if we need to sync the table.
*
* @param int $category_id Term ID being edited.
*/
public function on_edit( $category_id ) {
global $wpdb;
if ( ! isset( $this->edited_product_cats[ $category_id ] ) ) {
return;
}
$category_object = get_term( $category_id, 'product_cat' );
$prev_parent = $this->edited_product_cats[ $category_id ];
$new_parent = $category_object->parent;
// No edits - no need to modify relationships.
if ( $prev_parent === $new_parent ) {
return;
}
$this->delete( $category_id, $prev_parent );
$this->update( $category_id );
}
/**
* When a product category gets created, add a new lookup row.
*
* @param int $category_id Term ID being created.
*/
public function on_create( $category_id ) {
// If WooCommerce is being installed on a multisite, lookup tables haven't been created yet.
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return;
}
$this->update( $category_id );
}
/**
* Delete lookup table data from a tree.
*
* @param int $category_id Category ID to delete.
* @param int $category_tree_id Tree to delete from.
* @return void
*/
protected function delete( $category_id, $category_tree_id ) {
global $wpdb;
if ( ! $category_tree_id ) {
return;
}
$ancestors = get_ancestors( $category_tree_id, 'product_cat', 'taxonomy' );
$ancestors[] = $category_tree_id;
$children = get_term_children( $category_id, 'product_cat' );
$children[] = $category_id;
$id_list = implode( ',', array_map( 'intval', array_unique( array_filter( $children ) ) ) );
foreach ( $ancestors as $ancestor ) {
$wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d AND category_id IN ({$id_list})", $ancestor ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
/**
* Updates lookup table data for a category by ID.
*
* @param int $category_id Category ID to update.
*/
protected function update( $category_id ) {
global $wpdb;
$ancestors = get_ancestors( $category_id, 'product_cat', 'taxonomy' );
$children = get_term_children( $category_id, 'product_cat' );
$inserts = array();
$inserts[] = $this->get_insert_sql( $category_id, $category_id );
$children_ids = array_map( 'intval', array_unique( array_filter( $children ) ) );
foreach ( $ancestors as $ancestor ) {
$inserts[] = $this->get_insert_sql( $category_id, $ancestor );
foreach ( $children_ids as $child_category_id ) {
$inserts[] = $this->get_insert_sql( $child_category_id, $ancestor );
}
}
$insert_string = implode( ',', $inserts );
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_id, category_tree_id) VALUES {$insert_string}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get category lookup table values to insert.
*
* @param int $category_id Category ID to insert.
* @param int $category_tree_id Tree to insert into.
* @return string
*/
protected function get_insert_sql( $category_id, $category_tree_id ) {
global $wpdb;
return $wpdb->prepare( '(%d,%d)', $category_id, $category_tree_id );
}
/**
* Used to construct insert query recursively.
*
* @param array $inserts Array of data to insert.
* @param array $terms Terms to insert.
* @param array $parents Parent IDs the terms belong to.
*/
protected function get_term_insert_values( &$inserts, $terms, $parents = array() ) {
foreach ( $terms as $term ) {
$insert_parents = array_merge( array( $term['term_id'] ), $parents );
foreach ( $insert_parents as $parent ) {
$inserts[] = array(
$parent,
$term['term_id'],
);
}
$this->get_term_insert_values( $inserts, $term['descendants'], $insert_parents );
}
}
/**
* Convert flat terms array into nested array.
*
* @param array $hierarchy Array to put terms into.
* @param array $terms Array of terms (id=>parent).
* @param integer $parent Parent ID.
*/
protected function unflatten_terms( &$hierarchy, &$terms, $parent = 0 ) {
foreach ( $terms as $term_id => $parent_id ) {
if ( (int) $parent_id === $parent ) {
$hierarchy[ $term_id ] = array(
'term_id' => $term_id,
'descendants' => array(),
);
unset( $terms[ $term_id ] );
}
}
foreach ( $hierarchy as $term_id => $terms_array ) {
$this->unflatten_terms( $hierarchy[ $term_id ]['descendants'], $terms, $term_id );
}
}
/**
* Get category descendants.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_descendants( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_id FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d",
$category_id
)
)
);
}
/**
* Return all ancestor category ids for a category.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_ancestors( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_tree_id FROM $wpdb->wc_category_lookup WHERE category_id = %d",
$category_id
)
)
);
}
/**
* Add category lookup table to $wpdb object.
*/
public static function define_category_lookup_tables_in_wpdb() {
global $wpdb;
// List of tables without prefixes.
$tables = array(
'wc_category_lookup' => 'wc_category_lookup',
);
foreach ( $tables as $name => $table ) {
$wpdb->$name = $wpdb->prefix . $table;
$wpdb->tables[] = $table;
}
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* WooCommerce Marketing > Coupons.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Coupons feature.
*/
class Coupons {
use CouponsMovedTrait;
/**
* Class instance.
*
* @var Coupons instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
// If the main marketing feature is disabled, don't modify coupon behavior.
if ( ! Features::is_enabled( 'marketing' ) ) {
return;
}
// Only support coupon modifications if coupons are enabled.
if ( ! wc_coupons_enabled() ) {
return;
}
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_add_marketing_coupon_script' ) );
add_action( 'woocommerce_register_post_type_shop_coupon', array( $this, 'move_coupons' ) );
add_action( 'admin_head', array( $this, 'fix_coupon_menu_highlight' ), 99 );
add_action( 'admin_menu', array( $this, 'maybe_add_coupon_menu_redirect' ) );
}
/**
* Maybe add menu item back in original spot to help people transition
*/
public function maybe_add_coupon_menu_redirect() {
if ( ! $this->should_display_legacy_menu() ) {
return;
}
add_submenu_page(
'woocommerce',
__( 'Coupons', 'woocommerce' ),
__( 'Coupons', 'woocommerce' ),
'manage_options',
'coupons-moved',
array( $this, 'coupon_menu_moved' )
);
}
/**
* Call back for transition menu item
*/
public function coupon_menu_moved() {
wp_safe_redirect( $this->get_legacy_coupon_url(), 301 );
exit();
}
/**
* Modify registered post type shop_coupon
*
* @param array $args Array of post type parameters.
*
* @return array the filtered parameters.
*/
public function move_coupons( $args ) {
$args['show_in_menu'] = current_user_can( 'manage_woocommerce' ) ? 'woocommerce-marketing' : true;
return $args;
}
/**
* Undo WC modifications to $parent_file for 'shop_coupon'
*/
public function fix_coupon_menu_highlight() {
global $parent_file, $post_type;
if ( $post_type === 'shop_coupon' ) {
$parent_file = 'woocommerce-marketing'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
}
}
/**
* Maybe add our wc-admin coupon scripts if viewing coupon pages
*/
public function maybe_add_marketing_coupon_script() {
$curent_screen = PageController::get_instance()->get_current_page();
if ( ! isset( $curent_screen['id'] ) || $curent_screen['id'] !== 'woocommerce-coupons' ) {
return;
}
WCAdminAssets::register_style( 'marketing-coupons', 'style' );
WCAdminAssets::register_script( 'wp-admin-scripts', 'marketing-coupons', true );
}
}

View File

@@ -0,0 +1,117 @@
<?php
/**
* A Trait to help with managing the legacy coupon menu.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
/**
* CouponsMovedTrait trait.
*/
trait CouponsMovedTrait {
/**
* The GET query key for the legacy menu.
*
* @var string
*/
protected static $query_key = 'legacy_coupon_menu';
/**
* The key for storing an option in the DB.
*
* @var string
*/
protected static $option_key = 'wc_admin_show_legacy_coupon_menu';
/**
* Get the URL for the legacy coupon management.
*
* @return string The unescaped URL for the legacy coupon management page.
*/
protected static function get_legacy_coupon_url() {
return self::get_coupon_url( [ self::$query_key => true ] );
}
/**
* Get the URL for the coupon management page.
*
* @param array $args Additional URL query arguments.
*
* @return string
*/
protected static function get_coupon_url( $args = [] ) {
$args = array_merge(
[
'post_type' => 'shop_coupon',
],
$args
);
return add_query_arg( $args, admin_url( 'edit.php' ) );
}
/**
* Get the new URL for managing coupons.
*
* @param string $page The management page.
*
* @return string
*/
protected static function get_management_url( $page ) {
$path = '';
switch ( $page ) {
case 'coupon':
case 'coupons':
return self::get_coupon_url();
case 'marketing':
$path = self::get_marketing_path();
break;
}
return "wc-admin&path={$path}";
}
/**
* Get the WC Admin path for the marking page.
*
* @return string
*/
protected static function get_marketing_path() {
return '/marketing/overview';
}
/**
* Whether we should display the legacy coupon menu item.
*
* @return bool
*/
protected static function should_display_legacy_menu() {
/**
* Filter to determine whether to display the legacy coupon menu item.
*
* @since 10.5.0
*
* @param bool $display Whether the menu should be displayed or not.
* @return bool
*/
return apply_filters(
'wc_admin_show_legacy_coupon_menu',
! Features::is_enabled( 'navigation' )
);
}
/**
* Set whether we should display the legacy coupon menu item.
*
* @deprecated 10.5.0 No longer in use.
*
* @param bool $display Whether the menu should be displayed or not.
*/
protected static function display_legacy_menu( $display = false ) {
update_option( self::$option_key, $display ? 1 : 0 );
}
}

View File

@@ -0,0 +1,654 @@
<?php
/**
* WooCommerce Customer effort score tracks
*
* @package WooCommerce\Admin\Features
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Triggers customer effort score on several different actions.
*/
class CustomerEffortScoreTracks {
/**
* Option name for the CES Tracks queue.
*/
const CES_TRACKS_QUEUE_OPTION_NAME = 'woocommerce_ces_tracks_queue';
/**
* Option name for the clear CES Tracks queue for page.
*/
const CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME =
'woocommerce_clear_ces_tracks_queue_for_page';
/**
* Option name for the set of actions that have been shown.
*/
const SHOWN_FOR_ACTIONS_OPTION_NAME = 'woocommerce_ces_shown_for_actions';
/**
* Action name for product add/publish.
*/
const PRODUCT_ADD_PUBLISH_ACTION_NAME = 'product_add_publish';
/**
* Action name for product update.
*/
const PRODUCT_UPDATE_ACTION_NAME = 'product_update';
/**
* Action name for shop order update.
*/
const SHOP_ORDER_UPDATE_ACTION_NAME = 'shop_order_update';
/**
* Action name for settings change.
*/
const SETTINGS_CHANGE_ACTION_NAME = 'settings_change';
/**
* Action name for add product categories.
*/
const ADD_PRODUCT_CATEGORIES_ACTION_NAME = 'add_product_categories';
/**
* Action name for add product tags.
*/
const ADD_PRODUCT_TAGS_ACTION_NAME = 'add_product_tags';
/*
* Action name for add product attributes.
*/
const ADD_PRODUCT_ATTRIBUTES_ACTION_NAME = 'add_product_attributes';
/**
* Action name for import products.
*/
const IMPORT_PRODUCTS_ACTION_NAME = 'import_products';
/**
* Action name for search.
*/
const SEARCH_ACTION_NAME = 'ces_search';
/**
* Label for the snackbar that appears when a user submits the survey.
*
* @var string
*/
private $onsubmit_label;
/**
* Constructor. Sets up filters to hook into WooCommerce.
*/
public function __construct() {
$this->enable_survey_enqueing_if_tracking_is_enabled();
}
/**
* Add actions that require woocommerce_allow_tracking.
*/
private function enable_survey_enqueing_if_tracking_is_enabled() {
// Only hook up the action handlers if in wp-admin.
if ( ! is_admin() ) {
return;
}
// Do not hook up the action handlers if a mobile device is used.
if ( wp_is_mobile() ) {
return;
}
// Only enqueue a survey if tracking is allowed.
$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking', 'no' );
if ( ! $allow_tracking ) {
return;
}
add_action( 'admin_init', array( $this, 'maybe_clear_ces_tracks_queue' ) );
add_action( 'woocommerce_update_options', array( $this, 'run_on_update_options' ), 10, 3 );
add_action( 'product_cat_add_form', array( $this, 'add_script_track_product_categories' ), 10, 3 );
add_action( 'product_tag_add_form', array( $this, 'add_script_track_product_tags' ), 10, 3 );
add_action( 'woocommerce_attribute_added', array( $this, 'run_on_add_product_attributes' ), 10, 3 );
add_action( 'load-edit.php', array( $this, 'run_on_load_edit_php' ), 10, 3 );
add_action( 'product_page_product_importer', array( $this, 'run_on_product_import' ), 10, 3 );
// Only hook up the transition_post_status action handler
// if on the edit page.
global $pagenow;
if ( 'post.php' === $pagenow ) {
add_action(
'transition_post_status',
array(
$this,
'run_on_transition_post_status',
),
10,
3
);
}
$this->onsubmit_label = __( 'Thank you for your feedback!', 'woocommerce' );
}
/**
* Returns a generated script for tracking tags added on edit-tags.php page.
* CES survey is triggered via direct access to wc/customer-effort-score store
* via wp.data.dispatch method.
*
* Due to lack of options to directly hook ourselves into the ajax post request
* initiated by edit-tags.php page, we infer a successful request by observing
* an increase of the number of rows in tags table
*
* @param string $action Action name for the survey.
* @param string $title Title for the snackbar.
* @param string $first_question The text for the first question.
* @param string $second_question The text for the second question.
*
* @return string Generated JavaScript to append to page.
*/
private function get_script_track_edit_php( $action, $title, $first_question, $second_question ) {
return sprintf(
"(function( $ ) {
'use strict';
// Hook on submit button and sets a 1000ms interval function
// to determine successful add tag or otherwise.
$('#addtag #submit').on( 'click', function() {
const initialCount = $('.tags tbody > tr').length;
const interval = setInterval( function() {
if ( $('.tags tbody > tr').length > initialCount ) {
// New tag detected.
clearInterval( interval );
wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onsubmitLabel: '%s' });
} else {
// Form is no longer loading, most likely failed.
if ( $( '#addtag .submit .spinner.is-active' ).length < 1 ) {
clearInterval( interval );
}
}
}, 1000 );
});
})( jQuery );",
esc_js( $action ),
esc_js( $title ),
esc_js( $first_question ),
esc_js( $second_question ),
esc_js( $this->onsubmit_label )
);
}
/**
* Get the current published product count.
*
* @return integer The current published product count.
*/
private function get_product_count() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
$product_count = intval( $products->total );
return $product_count;
}
/**
* Get the current shop order count.
*
* @return integer The current shop order count.
*/
private function get_shop_order_count() {
$query = new \WC_Order_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
)
);
$shop_orders = $query->get_orders();
$shop_order_count = intval( $shop_orders->total );
return $shop_order_count;
}
/**
* Return whether the action has already been shown.
*
* @param string $action The action to check.
*
* @return bool Whether the action has already been shown.
*/
private function has_been_shown( $action ) {
$shown_for_features = get_option( self::SHOWN_FOR_ACTIONS_OPTION_NAME, array() );
$has_been_shown = in_array( $action, $shown_for_features, true );
return $has_been_shown;
}
/**
* Enqueue the item to the CES tracks queue.
*
* @param array $item The item to enqueue.
*/
private function enqueue_to_ces_tracks( $item ) {
$queue = get_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array()
);
$queue = is_array( $queue ) ? $queue : array();
$has_duplicate = array_filter(
$queue,
function ( $queue_item ) use ( $item ) {
return $queue_item['action'] === $item['action'];
}
);
if ( $has_duplicate ) {
return;
}
$queue[] = $item;
update_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
$queue
);
}
/**
* Enqueue the CES survey on using search dynamically.
*
* @param string $search_area Search area such as "product" or "shop_order".
* @param string $page_now Value of window.pagenow.
* @param string $admin_page Value of window.adminpage.
*/
public function enqueue_ces_survey_for_search( $search_area, $page_now, $admin_page ) {
if ( $this->has_been_shown( self::SEARCH_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SEARCH_ACTION_NAME,
'title' => __(
'How easy was it to use search?',
'woocommerce'
),
'firstQuestion' => __(
'The search feature in WooCommerce is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The search\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => $page_now,
'adminpage' => $admin_page,
'props' => (object) array(
'search_area' => $search_area,
),
)
);
}
/**
* Hook into the post status lifecycle, to detect relevant user actions
* that we want to survey about.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
* @param Post $post The post.
*/
public function run_on_transition_post_status(
$new_status,
$old_status,
$post
) {
if ( 'product' === $post->post_type ) {
$this->maybe_enqueue_ces_survey_for_product( $new_status, $old_status );
} elseif ( 'shop_order' === $post->post_type ) {
$this->enqueue_ces_survey_for_edited_shop_order();
}
}
/**
* Maybe enqueue the CES survey, if product is being added or edited.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
*/
private function maybe_enqueue_ces_survey_for_product(
$new_status,
$old_status
) {
if ( 'publish' !== $new_status ) {
return;
}
if ( 'publish' !== $old_status ) {
$this->enqueue_ces_survey_for_new_product();
} else {
$this->enqueue_ces_survey_for_edited_product();
}
}
/**
* Enqueue the CES survey trigger for a new product.
*/
private function enqueue_ces_survey_for_new_product() {
if ( $this->has_been_shown( self::PRODUCT_ADD_PUBLISH_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::PRODUCT_ADD_PUBLISH_ACTION_NAME,
'title' => __(
'🎉 Congrats on adding your first product!',
'woocommerce'
),
'firstQuestion' => __(
'The product creation screen is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The product creation screen\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product',
'adminpage' => 'post-php',
'props' => array(
'product_count' => $this->get_product_count(),
),
)
);
}
/**
* Enqueue the CES survey trigger for an existing product.
*/
private function enqueue_ces_survey_for_edited_product() {
if ( $this->has_been_shown( self::PRODUCT_UPDATE_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::PRODUCT_UPDATE_ACTION_NAME,
'title' => __(
'How easy was it to edit your product?',
'woocommerce'
),
'firstQuestion' => __(
'The product update process is easy to complete.',
'woocommerce'
),
'secondQuestion' => __(
'The product update process meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product',
'adminpage' => 'post-php',
'props' => array(
'product_count' => $this->get_product_count(),
),
)
);
}
/**
* Enqueue the CES survey trigger for an existing shop order.
*/
private function enqueue_ces_survey_for_edited_shop_order() {
if ( $this->has_been_shown( self::SHOP_ORDER_UPDATE_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SHOP_ORDER_UPDATE_ACTION_NAME,
'title' => __(
'How easy was it to update an order?',
'woocommerce'
),
'firstQuestion' => __(
'The order details screen is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The order details screen\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'shop_order',
'adminpage' => 'post-php',
'props' => array(
'order_count' => $this->get_shop_order_count(),
),
)
);
}
/**
* Maybe clear the CES tracks queue, executed on every page load. If the
* clear option is set it clears the queue. In practice, this executes a
* page load after the queued CES tracks are displayed on the client, which
* sets the clear option.
*/
public function maybe_clear_ces_tracks_queue() {
$clear_ces_tracks_queue_for_page = get_option(
self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME,
false
);
if ( ! $clear_ces_tracks_queue_for_page ) {
return;
}
$queue = get_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array()
);
$queue = is_array( $queue ) ? $queue : array();
$remaining_items = array_filter(
$queue,
function ( $item ) use ( $clear_ces_tracks_queue_for_page ) {
return $clear_ces_tracks_queue_for_page['pagenow'] !== $item['pagenow']
|| $clear_ces_tracks_queue_for_page['adminpage'] !== $item['adminpage'];
}
);
update_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array_values( $remaining_items )
);
update_option( self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME, false );
}
/**
* Appends a script to footer to trigger CES on adding product categories.
*/
public function add_script_track_product_categories() {
if ( $this->has_been_shown( self::ADD_PRODUCT_CATEGORIES_ACTION_NAME ) ) {
return;
}
$handle = 'wc-tracks-customer-effort-score-product-categories';
wp_register_script( $handle, '', array( 'jquery' ), WC_VERSION, true );
wp_enqueue_script( $handle );
wp_add_inline_script(
$handle,
$this->get_script_track_edit_php(
self::ADD_PRODUCT_CATEGORIES_ACTION_NAME,
__( 'How easy was it to add product category?', 'woocommerce' ),
__( 'The product category details screen is easy to use.', 'woocommerce' ),
__( "The product category details screen's functionality meets my needs.", 'woocommerce' )
)
);
}
/**
* Appends a script to footer to trigger CES on adding product tags.
*/
public function add_script_track_product_tags() {
if ( $this->has_been_shown( self::ADD_PRODUCT_TAGS_ACTION_NAME ) ) {
return;
}
$handle = 'wc-tracks-customer-effort-score-product-tags';
wp_register_script( $handle, '', array( 'jquery' ), WC_VERSION, true );
wp_enqueue_script( $handle );
wp_add_inline_script(
$handle,
$this->get_script_track_edit_php(
self::ADD_PRODUCT_TAGS_ACTION_NAME,
__( 'How easy was it to add a product tag?', 'woocommerce' ),
__( 'The product tag details screen is easy to use.', 'woocommerce' ),
__( "The product tag details screen's functionality meets my needs.", 'woocommerce' )
)
);
}
/**
* Maybe enqueue the CES survey on product import, if step is done.
*/
public function run_on_product_import() {
// We're only interested in when the importer completes.
if ( empty( $_GET['step'] ) || 'done' !== $_GET['step'] ) { // phpcs:ignore CSRF ok.
return;
}
if ( $this->has_been_shown( self::IMPORT_PRODUCTS_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::IMPORT_PRODUCTS_ACTION_NAME,
'title' => __(
'How easy was it to import products?',
'woocommerce'
),
'firstQuestion' => __(
'The product import process is easy to complete.',
'woocommerce'
),
'secondQuestion' => __(
'The product import process meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product_page_product_importer',
'adminpage' => 'product_page_product_importer',
'props' => (object) array(),
)
);
}
/**
* Enqueue the CES survey trigger for setting changes.
*/
public function run_on_update_options() {
// $current_tab is set when WC_Admin_Settings::save_settings is called.
global $current_tab;
global $current_section;
if ( $this->has_been_shown( self::SETTINGS_CHANGE_ACTION_NAME ) ) {
return;
}
$props = array(
'settings_area' => $current_tab,
);
if ( $current_section ) {
$props['settings_section'] = $current_section;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SETTINGS_CHANGE_ACTION_NAME,
'title' => __(
'How easy was it to update your settings?',
'woocommerce'
),
'firstQuestion' => __(
'The settings screen is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The settings screen\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'woocommerce_page_wc-settings',
'adminpage' => 'woocommerce_page_wc-settings',
'props' => (object) $props,
)
);
}
/**
* Enqueue the CES survey on adding new product attributes.
*/
public function run_on_add_product_attributes() {
if ( $this->has_been_shown( self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME,
'title' => __(
'How easy was it to add a product attribute?',
'woocommerce'
),
'firstQuestion' => __(
'Product attributes are easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'Product attributes\' functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product_page_product_attributes',
'adminpage' => 'product_page_product_attributes',
'props' => (object) array(),
)
);
}
/**
* Determine on initiating CES survey on searching for product or orders.
*/
public function run_on_load_edit_php() {
$allowed_types = array( 'product', 'shop_order' );
$post_type = get_current_screen()->post_type;
// We're only interested for certain post types.
if ( ! in_array( $post_type, $allowed_types, true ) ) {
return;
}
// Determine whether request is search by "s" GET parameter.
if ( empty( $_GET['s'] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
return;
}
$page_now = 'edit-' . $post_type;
$this->enqueue_ces_survey_for_search( $post_type, $page_now, 'edit-php' );
}
}

View File

@@ -0,0 +1,265 @@
<?php
/**
* Helper class to gradually enable email improvements to existing merchants.
*
* @since 9.9.0
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\EmailImprovements;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use WC_Tracker;
defined( 'ABSPATH' ) || exit;
/**
* EmailImprovements Class.
*/
class EmailImprovements {
/**
* Non-exhaustive list of email customizers.
*
* @var string[]
*/
private const EMAIL_CUSTOMIZERS = array(
'aco-email-customizer-and-designer-for-woocommerce.php',
'decorator.php',
'email-customizer-for-woocommerce.php',
'email-customizer-pro.php',
'kadence-woocommerce-email-designer.php',
'mailpoet.php',
'wp-html-mail.php',
'yaymail.php',
);
private const EMAIL_TEMPLATE_PARTS = array(
'email-addresses.php',
'email-customer-details.php',
'email-downloads.php',
'email-footer.php',
'email-header.php',
'email-mobile-messaging.php',
'email-order-details.php',
'email-order-items.php',
'email-styles.php',
);
/**
* Hook into WordPress.
*/
public function __construct() {
add_action( 'admin_init', array( __CLASS__, 'add_email_improvements_modal_to_url' ) );
}
/**
* Check if any core emails are being overridden by a template override.
*
* @return bool True if core emails are being overridden, false otherwise.
*/
public static function has_email_templates_overridden() {
$all_template_overrides = WC_Tracker::get_all_template_overrides();
$core_email_overrides = self::get_core_email_overrides( $all_template_overrides );
return count( $core_email_overrides ) > 0;
}
/**
* Check if any of the email customizers is enabled.
*
* @return bool True if any of the email customizers is enabled, false otherwise.
*/
public static function is_email_customizer_enabled() {
$all_plugins = WC_Tracker::get_all_plugins();
$active_plugins = $all_plugins['active_plugins'];
$plugin_slugs = array_map(
function ( $plugin_path ) {
$parts = explode( '/', $plugin_path );
return end( $parts );
},
array_keys( $active_plugins )
);
return count( array_intersect( self::EMAIL_CUSTOMIZERS, $plugin_slugs ) ) > 0;
}
/**
* Check if email improvements are enabled for existing stores.
*
* @return bool True if email improvements are enabled for existing stores, false otherwise.
*/
public static function is_email_improvements_enabled_for_existing_stores() {
$is_feature_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
$is_enabled_for_existing_stores = 'yes' === get_option( 'woocommerce_email_improvements_existing_store_enabled' );
return $is_feature_enabled && $is_enabled_for_existing_stores;
}
/**
* Check if email improvements should be enabled for existing stores.
* - The feature is not already enabled.
* - The feature was not manually disabled.
* - The email templates are not overridden.
* - The email customizer is not enabled.
*
* @return bool True if email improvements should be enabled for existing stores, false otherwise.
*/
public static function should_enable_email_improvements_for_existing_stores() {
if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
return false;
}
$manually_disabled_before = get_option( 'woocommerce_email_improvements_last_disabled_at' );
if ( $manually_disabled_before ) {
return false;
}
if ( self::has_email_templates_overridden() ) {
return false;
}
if ( self::is_email_customizer_enabled() ) {
return false;
}
// Temporarily paused roll-out to gather more feedback.
return false;
}
/**
* Check if we should notice the merchant about email improvements.
*
* @return bool True if we should notice the merchant about email improvements, false otherwise.
*/
public static function should_notify_merchant_about_email_improvements() {
return ! FeaturesUtil::feature_is_enabled( 'email_improvements' );
}
/**
* Add email improvements modal parameter to the URL when loading the WooCommerce Home page.
*
* @return void
*/
public static function add_email_improvements_modal_to_url() {
// Check if we're on the WooCommerce Home page.
if ( ! isset( $_GET['page'] ) || 'wc-admin' !== $_GET['page'] || isset( $_GET['path'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$dismissed_modal = get_option( 'woocommerce_admin_dismissed_email_improvements_modal' );
if ( 'yes' !== $dismissed_modal && self::is_email_improvements_enabled_for_existing_stores() ) {
update_option( 'woocommerce_admin_dismissed_email_improvements_modal', 'yes' );
wp_safe_redirect( add_query_arg( 'emailImprovementsModal', 'enabled' ) );
exit;
}
$dismissed_modal = get_option( 'woocommerce_admin_dismissed_try_email_improvements_modal' );
if ( 'yes' !== $dismissed_modal && self::should_notify_merchant_about_email_improvements() ) {
update_option( 'woocommerce_admin_dismissed_try_email_improvements_modal', 'yes' );
wp_safe_redirect( add_query_arg( 'emailImprovementsModal', 'try' ) );
exit;
}
}
/**
* Get all core emails.
*
* @return array Core emails.
*/
public static function get_core_emails() {
return array_filter(
self::get_emails(),
function ( $email ) {
return strpos( get_class( $email ), 'WC_Email_' ) === 0 && is_string( $email->template_html );
}
);
}
/**
* Get all core email template overrides.
*
* @param array $template_overrides All template overrides.
* @return array Core email template overrides.
*/
public static function get_core_email_overrides( $template_overrides ) {
$core_emails = self::get_core_emails();
$core_email_templates = array_map(
function ( $email ) {
return basename( $email->template_html );
},
$core_emails
);
$all_email_templates = array_merge( $core_email_templates, self::EMAIL_TEMPLATE_PARTS );
return array_intersect( $all_email_templates, $template_overrides );
}
/**
* Get all enabled email IDs.
*
* @return array Enabled email IDs.
*/
public static function get_enabled_emails() {
$enabled_emails = array_filter(
self::get_emails(),
function ( $email ) {
return $email->is_enabled() && ! $email->is_manual();
}
);
return array_values( array_map( fn( $email ) => get_class( $email ), $enabled_emails ) );
}
/**
* Get all disabled email IDs.
*
* @return array Enabled email IDs.
*/
public static function get_disabled_emails() {
$disabled_emails = array_filter(
self::get_emails(),
function ( $email ) {
return ! $email->is_enabled() && ! $email->is_manual();
}
);
return array_values( array_map( fn( $email ) => get_class( $email ), $disabled_emails ) );
}
/**
* Get all enabled or manual emails with Cc or Bcc.
*
* @return array Enabled or manual emails with Cc or Bcc.
*/
public static function get_enabled_or_manual_emails_with_cc_or_bcc() {
$enabled_or_manual_emails = array_filter(
self::get_emails(),
function ( $email ) {
return $email->is_enabled() || $email->is_manual();
}
);
$email_ids_with_cc = array();
$email_ids_with_bcc = array();
foreach ( $enabled_or_manual_emails as $email ) {
if ( $email->get_cc_recipient() ) {
$email_ids_with_cc[] = get_class( $email );
}
if ( $email->get_bcc_recipient() ) {
$email_ids_with_bcc[] = get_class( $email );
}
}
return array(
'ccs' => $email_ids_with_cc,
'bccs' => $email_ids_with_bcc,
);
}
/**
* A helper method to filter out non-WC_Email objects.
*
* @return \WC_Email[] All WC_Email objects.
*/
private static function get_emails() {
$emails = WC()->mailer()->get_emails();
return array_filter(
$emails,
fn( $email ) => is_object( $email ) && $email instanceof \WC_Email
);
}
}

View File

@@ -0,0 +1,822 @@
<?php
/**
* Renders the email preview.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\EmailPreview;
use Automattic\WooCommerce\Internal\EmailEditor\WooContentProcessor;
use Automattic\WooCommerce\Enums\OrderStatus;
use Throwable;
use WC_Email;
use WC_Order;
use WC_Order_Item_Product;
use WC_Order_Item_Shipping;
use WC_Product;
use WC_Product_Variation;
use WP_User;
defined( 'ABSPATH' ) || exit;
/**
* EmailPreview Class.
*/
class EmailPreview {
const DEFAULT_EMAIL_TYPE = 'WC_Email_Customer_Processing_Order';
const DEFAULT_EMAIL_ID = 'customer_processing_order';
const USER_OBJECT_EMAILS = array(
'WC_Email_Customer_New_Account',
'WC_Email_Customer_Reset_Password',
);
const TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS = 'woocommerce_preview_email_improvements';
/**
* All fields IDs that can customize email styles in Settings.
*
* @var array
*/
private static array $email_style_setting_ids = array(
'woocommerce_email_background_color',
'woocommerce_email_base_color',
'woocommerce_email_body_background_color',
'woocommerce_email_font_family',
'woocommerce_email_footer_text',
'woocommerce_email_footer_text_color',
'woocommerce_email_header_alignment',
'woocommerce_email_header_image',
'woocommerce_email_header_image_width',
'woocommerce_email_text_color',
);
/**
* All fields IDs that can customize specific email content in Settings.
*
* @var array
*/
private static array $email_content_setting_ids = array();
/**
* Whether the email setting IDs are initialized.
*
* @var bool
*/
private static bool $email_setting_ids_initialized = false;
/**
* The email type to preview.
*
* @var string|null
*/
private ?string $email_type = null;
/**
* The email object.
*
* @var WC_Email|null
*/
private ?WC_Email $email = null;
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Whether the locale has been switched when rendering the preview.
*
* @var bool
*/
private bool $locale_switched = false;
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Get all email setting IDs.
*/
public static function get_all_email_setting_ids() {
if ( ! self::$email_setting_ids_initialized ) {
self::$email_setting_ids_initialized = true;
$emails = WC()->mailer()->get_emails();
foreach ( $emails as $email ) {
self::$email_content_setting_ids = array_merge(
self::$email_content_setting_ids,
self::get_email_content_setting_ids( $email->id )
);
}
self::$email_content_setting_ids = array_unique( self::$email_content_setting_ids );
}
return array_merge(
self::$email_style_setting_ids,
self::$email_content_setting_ids,
);
}
/**
* Get email style setting IDs.
*/
public static function get_email_style_setting_ids() {
/**
* Filter the email style setting IDs. Email preview automatically refreshes when these settings are changed.
*
* @param array $setting_ids The email style setting IDs.
*
* @since 9.8.0
*/
return apply_filters( 'woocommerce_email_preview_email_style_setting_ids', self::$email_style_setting_ids );
}
/**
* Get email content setting IDs for specific email.
*
* @param string|null $email_id Email ID.
*/
public static function get_email_content_setting_ids( ?string $email_id ) {
if ( ! $email_id ) {
return array();
}
$setting_ids = array(
"woocommerce_{$email_id}_subject",
"woocommerce_{$email_id}_heading",
"woocommerce_{$email_id}_additional_content",
"woocommerce_{$email_id}_email_type",
);
/**
* Filter the email content setting IDs for specific email. Email preview automatically refreshes when these settings are changed.
*
* @param array $setting_ids The email content setting IDs.
* @param string $email_id The email ID.
*
* @since 9.8.0
*/
return apply_filters( 'woocommerce_email_preview_email_content_setting_ids', $setting_ids, $email_id );
}
/**
* Set the email type to preview.
*
* @param string $email_type Email type.
*
* @throws \InvalidArgumentException When the email type is invalid.
*/
public function set_email_type( string $email_type ) {
$this->switch_to_site_locale();
$wc_emails = WC()->mailer()->get_emails();
$emails = array_combine(
array_map( 'get_class', $wc_emails ),
$wc_emails
);
if ( ! in_array( $email_type, array_keys( $emails ), true ) ) {
throw new \InvalidArgumentException( 'Invalid email type' );
}
$this->email_type = $email_type;
$this->email = $emails[ $email_type ];
$object = null;
if ( in_array( $email_type, self::USER_OBJECT_EMAILS, true ) ) {
$object = new WP_User( 0 );
$object->user_email = 'user_preview@example.com';
$object->user_login = 'user_preview';
$object->first_name = 'John';
$object->last_name = 'Doe';
$this->email->user_email = $object->user_email;
$this->email->user_login = $object->user_login;
if ( property_exists( $this->email, 'reset_key' ) ) {
$this->email->reset_key = 'reset_key';
}
if ( property_exists( $this->email, 'set_password_url' ) ) {
$this->email->set_password_url = 'https://example.com/set-password';
}
if ( property_exists( $this->email, 'user_id' ) ) {
$this->email->user_id = 0;
}
$this->email->set_object( $object );
} else {
$object = $this->get_dummy_order();
if ( 'WC_Email_Customer_Note' === $email_type ) {
$this->email->customer_note = $object->get_customer_note();
}
if ( 'WC_Email_Customer_Refunded_Order' === $email_type ) {
$this->email->partial_refund = false;
}
$this->email->set_object( $object );
}
$this->email->placeholders = array_merge(
$this->email->placeholders,
$this->get_placeholders( $object )
);
/**
* Allow to modify the email object before rendering the preview to add additional data.
*
* @param WC_Email $email The email object.
*
* @since 9.6.0
*/
$this->email = apply_filters( 'woocommerce_prepare_email_for_preview', $this->email );
$this->restore_locale();
}
/**
* Get the email object.
*
* @return WC_Email
*/
public function get_email() {
return $this->email;
}
/**
* Get the preview email content.
*
* @return string
*/
public function render() {
return $this->render_preview_email();
}
/**
* Ensure links open in new tab. User in WooCommerce Settings,
* so the links don't open inside the iframe.
*
* @param string $content Email content HTML.
* @return string
*/
public function ensure_links_open_in_new_tab( string $content ) {
if ( empty( $content ) || strpos( $content, '<a' ) === false ) {
return $content;
}
if ( ! class_exists( 'DOMDocument' ) ) {
return $content;
}
// Suppress libxml errors to prevent them from being displayed.
$previous_use_internal_errors = libxml_use_internal_errors( true );
try {
$dom = new \DOMDocument();
// Add UTF-8 encoding and load with error suppression flags.
$html_with_encoding = '<?xml encoding="UTF-8">' . $content;
$dom->loadHTML(
$html_with_encoding,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOWARNING | LIBXML_NOERROR
);
$links = $dom->getElementsByTagName( 'a' );
foreach ( $links as $link ) {
$link->setAttribute( 'target', '_blank' );
$link->setAttribute( 'rel', 'noopener' );
}
$result = $dom->saveHTML();
// Remove the XML declaration we added earlier, it's not meant to be used in an HTML document.
$result = preg_replace( '/<\?xml[^>]*>\s*/i', '', $result );
return $result;
} catch ( \Exception $e ) {
return $content;
} finally {
libxml_use_internal_errors( $previous_use_internal_errors );
libxml_clear_errors();
}
}
/**
* Get the preview email content.
*
* @return string
*/
public function get_subject() {
if ( ! $this->email ) {
return '';
}
$this->set_up_filters();
$subject = $this->email->get_subject();
$this->clean_up_filters();
return $subject;
}
/**
* Return a dummy product when the product is not set in email classes.
*
* @param WC_Product|null $product Order item product.
* @return WC_Product
*/
public function get_dummy_product_when_not_set( $product ) {
if ( $product ) {
return $product;
}
return $this->get_dummy_product();
}
/**
* Render HTML content of the preview email.
*
* @return string
*/
private function render_preview_email() {
if ( ! $this->email_type ) {
$this->set_email_type( self::DEFAULT_EMAIL_TYPE );
}
$this->set_up_filters();
if ( 'plain' === $this->email->get_email_type() ) {
$content = '<pre style="word-wrap: break-word; white-space: pre-wrap; text-align: ' . ( is_rtl() ? 'right' : 'left' ) . ';">';
$content .= $this->email->get_content_plain();
$content .= '</pre>';
} else {
$content = $this->email->get_content_html();
}
$inlined = $this->email->style_inline( $content );
$this->clean_up_filters();
/** This filter is documented in src/Internal/Admin/EmailPreview/EmailPreview.php */
return apply_filters( 'woocommerce_mail_content', $inlined ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
}
/**
* Get a dummy order object without the need to create in the database.
*
* @return WC_Order
*/
private function get_dummy_order() {
$product = $this->get_dummy_product();
$variation = $this->get_dummy_product_variation();
$downloadable_product = $this->get_dummy_downloadable_product();
$order = new WC_Order();
$order->set_id( 12345 );
// Create and add product items manually without saving to database.
// Use add_item() instead of add_product() to avoid immediate database writes.
if ( $product ) {
$item = new WC_Order_Item_Product();
$item->set_props(
array(
'name' => $product->get_name(),
'tax_class' => $product->get_tax_class(),
'product_id' => $product->get_id(),
'variation_id' => 0,
'quantity' => 2,
'subtotal' => $product->get_price() * 2,
'total' => $product->get_price() * 2,
)
);
$order->add_item( $item );
}
if ( $variation ) {
$item = new WC_Order_Item_Product();
$item->set_props(
array(
'name' => $variation->get_name(),
'tax_class' => $variation->get_tax_class(),
'product_id' => $variation->get_parent_id(),
'variation_id' => $variation->get_id(),
'variation' => $variation->get_attributes(),
'quantity' => 1,
'subtotal' => $variation->get_price(),
'total' => $variation->get_price(),
)
);
$order->add_item( $item );
}
if ( $downloadable_product ) {
$item = new WC_Order_Item_Product();
$item->set_props(
array(
'name' => $downloadable_product->get_name(),
'tax_class' => $downloadable_product->get_tax_class(),
'product_id' => $downloadable_product->get_id(),
'variation_id' => 0,
'quantity' => 1,
'subtotal' => $downloadable_product->get_price(),
'total' => $downloadable_product->get_price(),
)
);
$order->add_item( $item );
}
$order->set_date_created( time() );
$order->set_currency( 'USD' );
$order->set_discount_total( 10 );
$order->set_shipping_total( 5 );
$order->set_total( 80 );
$order->set_payment_method_title( __( 'Direct bank transfer', 'woocommerce' ) );
$order->set_transaction_id( '999999999' );
$order->set_customer_note( __( "This is a customer note. Customers can add a note to their order on checkout.\n\nIt can be multiple lines. If there's no note, this section is hidden.", 'woocommerce' ) );
$order = $this->apply_dummy_order_status( $order );
// Add shipping method.
$shipping_item = new WC_Order_Item_Shipping();
$shipping_item->set_props(
array(
'method_title' => __( 'Flat rate', 'woocommerce' ),
'method_id' => 'flat_rate',
'total' => '5.00',
)
);
$order->add_item( $shipping_item );
$address = $this->get_dummy_address();
$order->set_billing_address( $address );
$order->set_shipping_address( $address );
/**
* A dummy WC_Order used in email preview.
*
* @param WC_Order $order The dummy order object.
* @param string $email_type The email type to preview.
*
* @since 9.6.0
*/
return apply_filters( 'woocommerce_email_preview_dummy_order', $order, $this->email_type );
}
/**
* Apply a contextual status to the dummy order based on the previewed email type.
*
* @param WC_Order $order Dummy order instance.
* @return WC_Order
*/
private function apply_dummy_order_status( WC_Order $order ): WC_Order {
$email_type_status_map = array(
'WC_Email_Customer_Completed_Order' => OrderStatus::COMPLETED,
'WC_Email_Customer_Processing_Order' => OrderStatus::PROCESSING,
'WC_Email_Customer_On_Hold_Order' => OrderStatus::ON_HOLD,
'WC_Email_Customer_Failed_Order' => OrderStatus::FAILED,
'WC_Email_Customer_Cancelled_Order' => OrderStatus::CANCELLED,
'WC_Email_Customer_Refunded_Order' => OrderStatus::REFUNDED,
'WC_Email_New_Order' => OrderStatus::PROCESSING,
'WC_Email_Cancelled_Order' => OrderStatus::CANCELLED,
'WC_Email_Failed_Order' => OrderStatus::FAILED,
);
$status = $email_type_status_map[ $this->email_type ] ?? OrderStatus::PROCESSING;
$order->set_status( $status );
return $order;
}
/**
* Get a dummy product. Also used with `woocommerce_order_item_product` filter
* when email templates tries to get the product from the database.
*
* @return WC_Product
*/
private function get_dummy_product() {
$product = new WC_Product();
$product->set_name( __( 'Dummy Product', 'woocommerce' ) );
$product->set_price( 25 );
/**
* A dummy WC_Product used in email preview.
*
* @param WC_Product $product The dummy product object.
* @param string $email_type The email type to preview.
*
* @since 9.6.0
*/
return apply_filters( 'woocommerce_email_preview_dummy_product', $product, $this->email_type );
}
/**
* Get a dummy product variation.
*
* @return WC_Product_Variation
*/
private function get_dummy_product_variation() {
$variation = new WC_Product_Variation();
$variation->set_name( __( 'Dummy Product Variation', 'woocommerce' ) );
$variation->set_price( 20 );
$variation->set_attributes(
array(
__( 'Color', 'woocommerce' ) => __( 'Red', 'woocommerce' ),
__( 'Size', 'woocommerce' ) => __( 'Small', 'woocommerce' ),
)
);
/**
* A dummy WC_Product_Variation used in email preview.
*
* @param WC_Product_Variation $variation The dummy product variation object.
* @param string $email_type The email type to preview.
*
* @since 9.7.0
*/
return apply_filters( 'woocommerce_email_preview_dummy_product_variation', $variation, $this->email_type );
}
/**
* Get a dummy downloadable/virtual product.
*
* @return WC_Product
*/
private function get_dummy_downloadable_product() {
$product = new WC_Product();
$product->set_name( __( 'Dummy Downloadable Product', 'woocommerce' ) );
$product->set_price( 15 );
$product->set_virtual( true );
$product->set_downloadable( true );
/**
* A dummy downloadable WC_Product used in email preview.
*
* @param WC_Product $product The dummy downloadable product object.
* @param string $email_type The email type to preview.
*
* @since 10.3.0
*/
return apply_filters( 'woocommerce_email_preview_dummy_downloadable_product', $product, $this->email_type );
}
/**
* Get a dummy address.
*
* @return array
*/
private function get_dummy_address() {
$address = array(
'first_name' => 'John',
'last_name' => 'Doe',
'company' => 'Company',
'email' => 'john@company.com',
'phone' => '555-555-5555',
'address_1' => '123 Fake Street',
'city' => 'Faketown',
'postcode' => '12345',
'country' => 'US',
'state' => 'CA',
);
/**
* A dummy address used in email preview as billing and shipping one.
*
* @param array $address The dummy address.
* @param string $email_type The email type to preview.
*
* @since 9.6.0
*/
return apply_filters( 'woocommerce_email_preview_dummy_address', $address, $this->email_type );
}
/**
* Get the placeholders for the email preview.
*
* @param mixed $email_object The object to render email with. Can be WC_Order, WP_User, etc.
* @return array
*/
private function get_placeholders( $email_object ) {
$placeholders = array();
if ( is_a( $email_object, 'WC_Order' ) ) {
$placeholders['{order_date}'] = wc_format_datetime( $email_object->get_date_created() );
$placeholders['{order_number}'] = $email_object->get_order_number();
$placeholders['{order_billing_full_name}'] = $email_object->get_formatted_billing_full_name();
}
/**
* Placeholders for email preview.
*
* @param array $placeholders Placeholders for email subject.
* @param string $email_type The email type to preview.
* @param mixed $email_object The object to render email with. @since 9.9.0
*
* @since 9.6.0
*/
return apply_filters( 'woocommerce_email_preview_placeholders', $placeholders, $this->email_type, $email_object );
}
/**
* Set up filters for email preview.
*/
public function set_up_filters() {
$this->switch_to_site_locale();
// Always show shipping address in the preview email.
add_filter( 'woocommerce_order_needs_shipping_address', array( $this, 'enable_shipping_address' ) );
// Email templates fetch product from the database to show additional information, which are not
// saved in WC_Order_Item_Product. This filter enables fetching that data also in email preview.
add_filter( 'woocommerce_order_item_product', array( $this, 'get_dummy_product_when_not_set' ), 10, 1 );
// Enable email preview mode - this way transient values are fetched for live preview.
add_filter( 'woocommerce_is_email_preview', array( $this, 'enable_preview_mode' ) );
// Use placeholder image included in WooCommerce files.
add_filter( 'woocommerce_order_item_thumbnail', array( $this, 'get_placeholder_image' ) );
// Make products in preview considered downloadable and provide dummy file so WC core shows downloads.
add_filter( 'woocommerce_is_downloadable', array( $this, 'force_product_downloadable' ), 10, 1 );
add_filter( 'woocommerce_product_file', array( $this, 'provide_dummy_product_file' ), 10, 1 );
// Provide dummy downloadable items for email preview.
add_filter( 'woocommerce_order_get_downloadable_items', array( $this, 'get_dummy_downloadable_items' ), 10, 1 );
}
/**
* Clean up filters after email preview.
*/
public function clean_up_filters() {
remove_filter( 'woocommerce_order_needs_shipping_address', array( $this, 'enable_shipping_address' ) );
remove_filter( 'woocommerce_order_item_product', array( $this, 'get_dummy_product_when_not_set' ), 10 );
remove_filter( 'woocommerce_is_email_preview', array( $this, 'enable_preview_mode' ) );
remove_filter( 'woocommerce_order_item_thumbnail', array( $this, 'get_placeholder_image' ) );
remove_filter( 'woocommerce_is_downloadable', array( $this, 'force_product_downloadable' ), 10 );
remove_filter( 'woocommerce_product_file', array( $this, 'provide_dummy_product_file' ), 10 );
remove_filter( 'woocommerce_order_get_downloadable_items', array( $this, 'get_dummy_downloadable_items' ), 10 );
$this->restore_locale();
}
/**
* Enable shipping address in the preview email. Not using __return_true so
* we don't accidentally remove the same filter used by other plugin or theme.
*
* @return true
*/
public function enable_shipping_address() {
return true;
}
/**
* Enable preview mode to use transient values in email-styles.php. Not using __return_true
* so we don't accidentally remove the same filter used by other plugin or theme.
*
* @return true
*/
public function enable_preview_mode() {
return true;
}
/**
* Get the placeholder image for the preview email.
*
* @return string
*/
public function get_placeholder_image() {
return '<img src="' . WC()->plugin_url() . '/assets/images/placeholder.webp" width="48" height="48" alt="" />';
}
/**
* Force products in preview to be considered downloadable so core renders downloads section.
*
* @param bool $is_downloadable Current value.
* @return bool
*/
public function force_product_downloadable( $is_downloadable ) {
/**
* Filters whether the current request is an email preview.
*
* When true, products should be considered downloadable so the downloads
* section renders in applicable emails during preview.
*
* @since 9.6.0
*
* @param bool $is_email_preview Whether preview mode is active.
*/
if ( apply_filters( 'woocommerce_is_email_preview', false ) ) {
return true;
}
return $is_downloadable;
}
/**
* Provide a dummy product file so product->has_file() returns true in preview.
*
* @param array|null $file Current file array or null.
* @return array|null
*/
public function provide_dummy_product_file( $file ) {
/**
* Filters whether the current request is an email preview.
*
* When true, provide a dummy product file array so downloadable template parts
* can render during preview.
*
* @since 9.6.0
*
* @param bool $is_email_preview Whether preview mode is active.
*/
if ( apply_filters( 'woocommerce_is_email_preview', false ) ) {
return array(
'name' => __( 'Sample Download File.pdf', 'woocommerce' ),
'file' => 'sample-download.pdf',
);
}
return $file;
}
/**
* Get dummy downloadable items for email preview.
*
* @param array $downloads Existing downloads.
* @return array
*/
public function get_dummy_downloadable_items( $downloads ) {
$dummy_downloads = array(
array(
'product_name' => $this->get_dummy_downloadable_product()->get_name(),
'product_id' => $this->get_dummy_downloadable_product()->get_id(),
'download_url' => 'https://example.com/download',
'download_name' => __( 'Sample Download File.pdf', 'woocommerce' ),
'access_expires' => time() + ( 30 * DAY_IN_SECONDS ),
),
);
return array_merge( $downloads, $dummy_downloads );
}
/**
* Generate placeholder content for a specific email type, typically used in the email editor.
*
* Encapsulates the logic for setting the email type, generating raw content, applying styles,
* ensuring links open in new tabs, and handling errors based on WP_DEBUG.
*
* @param string $email_type_class_name The class name of the WC_Email type (e.g., 'WC_Email_Customer_Processing_Order').
* @return string The generated and styled HTML content.
* @throws \RuntimeException If content generation fails. If rendering fails.
*/
public function generate_placeholder_content( string $email_type_class_name ): string {
// Note: set_email_type can throw InvalidArgumentException.
$this->set_email_type( $email_type_class_name );
$woo_content_processor = wc_get_container()->get( WooContentProcessor::class );
$generate_content_closure = function () use ( $woo_content_processor ) {
// Note: If 'woocommerce_email_styles' filter was intentional and `prepare_css` isn't
// the intended callback, adjust accordingly. This assumes `prepare_css` applies styles
// needed for the Woo content block.
add_filter( 'woocommerce_email_styles', array( $woo_content_processor, 'prepare_css' ), 10, 2 );
$content = $woo_content_processor->get_woo_content( $this->get_email() );
$content = $this->get_email()->style_inline( $content );
$content = $this->ensure_links_open_in_new_tab( $content );
return $content;
};
$this->set_up_filters();
$message = '';
try {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
$message = $generate_content_closure();
} else {
// Use output buffering to prevent partial renders with PHP notices or warnings when WP_DEBUG is off.
ob_start();
try {
$message = $generate_content_closure();
} catch ( Throwable $e ) {
ob_end_clean();
// Let the caller handle the exception.
throw new \RuntimeException( esc_html__( 'There was an error rendering the email editor placeholder content.', 'woocommerce' ), 0, $e );
}
ob_end_clean();
}
} finally {
$this->clean_up_filters();
}
return $message;
}
/**
* Switch to the site locale. This is to ensure the email is displayed
* in the store's language, as the customer would see it, not the admin's language.
*/
private function switch_to_site_locale() {
if ( ! $this->locale_switched ) {
wc_switch_to_site_locale();
$this->locale_switched = true;
}
}
/**
* Restore the original locale.
*/
private function restore_locale() {
if ( $this->locale_switched ) {
wc_restore_locale();
$this->locale_switched = false;
}
}
}

View File

@@ -0,0 +1,331 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\EmailPreview;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
use WP_Error;
use WP_REST_Request;
/**
* Controller for the REST endpoint to send an email preview.
*/
class EmailPreviewRestController extends RestApiControllerBase {
/**
* Email preview nonce.
*
* @var string
*/
const NONCE_KEY = 'email-preview-nonce';
/**
* Holds the EmailPreview instance for rendering email previews.
*
* @var EmailPreview
*/
private EmailPreview $email_preview;
/**
* The root namespace for the JSON REST API endpoints.
*
* @var string
*/
protected string $route_namespace = 'wc-admin-email';
/**
* Route base.
*
* @var string
*/
protected string $rest_base = 'settings/email';
/**
* Get the WooCommerce REST API namespace for the class.
*
* @return string
*/
protected function get_rest_api_namespace(): string {
return 'wc-admin-email';
}
/**
* The constructor.
*/
public function __construct() {
$this->email_preview = wc_get_container()->get( EmailPreview::class );
}
/**
* Register the REST API endpoints handled by this controller.
*/
public function register_routes() {
register_rest_route(
$this->route_namespace,
'/' . $this->rest_base . '/send-preview',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => fn( $request ) => $this->send_email_preview( $request ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'args' => $this->get_args_for_send_preview(),
'schema' => $this->get_schema_with_message(),
),
)
);
register_rest_route(
$this->route_namespace,
'/' . $this->rest_base . '/preview-subject',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => array(
'subject' => $this->email_preview->get_subject(),
),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'args' => $this->get_args_for_preview_subject(),
'schema' => $this->get_schema_for_preview_subject(),
),
)
);
register_rest_route(
$this->route_namespace,
'/' . $this->rest_base . '/save-transient',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => fn( $request ) => $this->save_transient( $request ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'args' => $this->get_args_for_save_transient(),
'schema' => $this->get_schema_with_message(),
),
)
);
}
/**
* Get the accepted arguments for the POST send-preview request.
*
* @return array[]
*/
private function get_args_for_send_preview() {
return array(
'type' => array(
'description' => __( 'The email type to preview.', 'woocommerce' ),
'type' => 'string',
'required' => true,
'validate_callback' => fn( $key ) => $this->validate_email_type( $key ),
'sanitize_callback' => 'sanitize_text_field',
),
'email' => array(
'description' => __( 'Email address to send the email preview to.', 'woocommerce' ),
'type' => 'string',
'format' => 'email',
'required' => true,
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_email',
),
);
}
/**
* Get the accepted arguments for the GET preview-subject request.
*
* @return array[]
*/
private function get_args_for_preview_subject() {
return array(
'type' => array(
'description' => __( 'The email type to get subject for.', 'woocommerce' ),
'type' => 'string',
'required' => true,
'validate_callback' => fn( $key ) => $this->validate_email_type( $key ),
'sanitize_callback' => 'sanitize_text_field',
),
);
}
/**
* Get the accepted arguments for the POST save-transient request.
*
* @return array[]
*/
private function get_args_for_save_transient() {
return array(
'key' => array(
'required' => true,
'type' => 'string',
'description' => 'The key for the transient. Must be one of the allowed options.',
'validate_callback' => function ( $key ) {
if ( ! in_array( $key, EmailPreview::get_all_email_setting_ids(), true ) ) {
return new \WP_Error(
'woocommerce_rest_not_allowed_key',
sprintf( 'The provided key "%s" is not allowed.', $key ),
array( 'status' => 400 ),
);
}
return true;
},
'sanitize_callback' => 'sanitize_text_field',
),
'value' => array(
'required' => true,
'type' => 'string',
'description' => 'The value to be saved for the transient.',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => function ( $value, $request ) {
$key = $request->get_param( 'key' );
if (
'woocommerce_email_footer_text' === $key
|| preg_match( '/_additional_content$/', $key )
) {
return wp_kses_post( trim( $value ) );
}
return sanitize_text_field( $value );
},
),
);
}
/**
* Get the schema for the POST send-preview and save-transient requests.
*
* @return array[]
*/
private function get_schema_with_message() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'email-preview-with-message',
'type' => 'object',
'properties' => array(
'message' => array(
'description' => __( 'A message indicating that the action completed successfully.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
}
/**
* Get the schema for the GET preview_subject request.
*
* @return array[]
*/
private function get_schema_for_preview_subject() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'email-preview-subject',
'type' => 'object',
'properties' => array(
'subject' => array(
'description' => __( 'A subject for provided email type after filters are applied and placeholders replaced.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view' ),
'readonly' => true,
),
),
);
}
/**
* Validate the email type.
*
* @param string $email_type The email type to validate.
* @return bool|WP_Error True if the email type is valid, otherwise a WP_Error object.
*/
private function validate_email_type( string $email_type ) {
try {
$this->email_preview->set_email_type( $email_type );
} catch ( \InvalidArgumentException $e ) {
return new WP_Error(
'woocommerce_rest_invalid_email_type',
__( 'Invalid email type.', 'woocommerce' ),
array( 'status' => 400 ),
);
}
return true;
}
/**
* Permission check for REST API endpoint.
*
* @param WP_REST_Request $request The request for which the permission is checked.
* @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error object.
*/
private function check_permissions( WP_REST_Request $request ) {
$nonce = $request->get_param( 'nonce' );
if ( ! wp_verify_nonce( $nonce, self::NONCE_KEY ) ) {
return new WP_Error(
'invalid_nonce',
__( 'Invalid nonce.', 'woocommerce' ),
array( 'status' => 403 ),
);
}
return $this->check_permission( $request, 'manage_woocommerce' );
}
/**
* Handle the POST /settings/email/send-preview.
*
* @param WP_REST_Request $request The received request.
* @return array|WP_Error Request response or an error.
*/
public function send_email_preview( WP_REST_Request $request ) {
$email_address = $request->get_param( 'email' );
// Start output buffering to prevent partial renders with PHP notices or warnings.
ob_start();
try {
$email_content = $this->email_preview->render();
} catch ( \Throwable $e ) {
ob_end_clean();
return new WP_Error(
'woocommerce_rest_email_preview_not_rendered',
__( 'There was an error rendering an email preview.', 'woocommerce' ),
array( 'status' => 500 )
);
}
ob_end_clean();
$email_subject = $this->email_preview->get_subject();
$email = new \WC_Emails();
$sent = $email->send( $email_address, $email_subject, $email_content );
if ( $sent ) {
return array(
// translators: %s: Email address.
'message' => sprintf( __( 'Test email sent to %s.', 'woocommerce' ), $email_address ),
);
}
return new WP_Error(
'woocommerce_rest_email_preview_not_sent',
__( 'Error sending test email. Please try again.', 'woocommerce' ),
array( 'status' => 500 )
);
}
/**
* Handle the POST /settings/email/save-transient.
*
* @param WP_REST_Request $request The received request.
* @return array|WP_Error Request response or an error.
*/
public function save_transient( WP_REST_Request $request ) {
$key = $request->get_param( 'key' );
$value = $request->get_param( 'value' );
$is_set = set_transient( $key, $value, HOUR_IN_SECONDS );
if ( ! $is_set ) {
return new WP_Error(
'woocommerce_rest_transient_not_set',
__( 'Error saving transient. Please try again.', 'woocommerce' ),
array( 'status' => 500 )
);
}
return array(
// translators: %s: Email settings color key, e.g., "woocommerce_email_base_color".
'message' => sprintf( __( 'Transient saved for key %s.', 'woocommerce' ), $key ),
);
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Emails;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use WP_Error;
use WP_REST_Request;
/**
* Controller for the REST endpoint for the new email listing page.
*/
class EmailListingRestController extends RestApiControllerBase {
/**
* Email listing nonce.
*
* @var string
*/
const NONCE_KEY = 'email-listing-nonce';
/**
* The root namespace for the JSON REST API endpoints.
*
* @var string
*/
protected string $route_namespace = 'wc-admin-email';
/**
* Route base.
*
* @var string
*/
protected string $rest_base = 'settings/email/listing';
/**
* Email template generator instance.
*
* @var WCTransactionalEmailPostsGenerator
*/
private $email_template_generator;
/**
* Get the WooCommerce REST API namespace for the class.
*
* @return string
*/
protected function get_rest_api_namespace(): string {
return 'wc-admin-email-listing';
}
/**
* The constructor.
*/
public function __construct() {
$this->email_template_generator = new WCTransactionalEmailPostsGenerator();
}
/**
* Perform the initialization.
*/
public function initialize_template_generator() {
$this->email_template_generator->init_default_transactional_emails();
}
/**
* Register the REST API endpoints handled by this controller.
*/
public function register_routes() {
$this->initialize_template_generator();
register_rest_route(
$this->route_namespace,
'/' . $this->rest_base . '/recreate-email-post',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => fn( $request ) => $this->recreate_email_post( $request ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'args' => $this->get_args_for_recreate_email_post(),
'schema' => $this->get_schema_with_message(),
),
)
);
}
/**
* Get the accepted arguments for the POST recreate-email-post request.
*
* @return array[]
*/
private function get_args_for_recreate_email_post() {
return array(
'email_id' => array(
'description' => __( 'The email ID to recreate the post for.', 'woocommerce' ),
'type' => 'string',
'required' => true,
'validate_callback' => fn( $email_id ) => $this->validate_email_id( $email_id ),
'sanitize_callback' => 'sanitize_text_field',
),
);
}
/**
* Get the schema for the POST recreate-email-post and save-transient requests.
*
* @return array[]
*/
private function get_schema_with_message() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'email-listing-with-message',
'type' => 'object',
'properties' => array(
'message' => array(
'description' => __( 'A message indicating that the action completed successfully.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'post_id' => array(
'description' => __( 'The post ID of the generated email post.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
}
/**
* Validate the email ID.
*
* @param string $email_id The email ID to validate.
* @return bool|WP_Error True if the email ID is valid, otherwise a WP_Error object.
*/
private function validate_email_id( string $email_id ) {
if ( ! in_array( $email_id, WCTransactionalEmails::get_transactional_emails(), true ) ) {
return new \WP_Error(
'woocommerce_rest_not_allowed_email_id',
sprintf( 'The provided email ID "%s" is not allowed.', $email_id ),
array( 'status' => 400 ),
);
}
return true;
}
/**
* Permission check for REST API endpoint.
*
* @param WP_REST_Request $request The request for which the permission is checked.
* @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error object.
*/
private function check_permissions( WP_REST_Request $request ) {
$nonce = $request->get_param( 'nonce' );
if ( ! wp_verify_nonce( $nonce, self::NONCE_KEY ) ) {
return new WP_Error(
'invalid_nonce',
__( 'Invalid nonce.', 'woocommerce' ),
array( 'status' => 403 ),
);
}
return $this->check_permission( $request, 'manage_woocommerce' );
}
/**
* Handle the POST /settings/email/listing/recreate-email-post.
*
* @param WP_REST_Request $request The received request.
* @return array|WP_Error Request response or an error.
*/
public function recreate_email_post( WP_REST_Request $request ) {
$email_id = $request->get_param( 'email_id' );
$generated_post_id = '';
try {
$generated_post_id = $this->email_template_generator->generate_email_template_if_not_exists( $email_id );
} catch ( \Exception $e ) {
return new WP_Error(
'woocommerce_rest_email_post_generation_failed',
// translators: %s: Error message.
sprintf( __( 'Error generating email post. Error: %s.', 'woocommerce' ), $e->getMessage() ),
array( 'status' => 500 )
);
}
if ( $generated_post_id ) {
return array(
// translators: %s: WooCommerce transactional email ID.
'message' => sprintf( __( 'Email post generated for %s.', 'woocommerce' ), $email_id ),
'post_id' => (string) $generated_post_id,
);
}
return new WP_Error(
'woocommerce_rest_email_post_generation_error',
__( 'Error unable to generate email post.', 'woocommerce' ),
array( 'status' => 500 )
);
}
}

View File

@@ -0,0 +1,271 @@
<?php
/**
* Handle cron events.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsDataSourcePoller;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
use Automattic\WooCommerce\Internal\Admin\Notes\CustomizeStoreWithBlocks;
use Automattic\WooCommerce\Internal\Admin\Notes\CustomizingProductCatalog;
use Automattic\WooCommerce\Internal\Admin\Notes\EditProductsOnTheMove;
use Automattic\WooCommerce\Internal\Admin\Notes\EmailImprovements;
use Automattic\WooCommerce\Internal\Admin\Notes\EUVATNumber;
use Automattic\WooCommerce\Internal\Admin\Notes\FirstProduct;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\LaunchChecklist;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Internal\Admin\Notes\ManageOrdersOnTheGo;
use Automattic\WooCommerce\Internal\Admin\Notes\MarketingJetpack;
use Automattic\WooCommerce\Internal\Admin\Notes\MigrateFromShopify;
use Automattic\WooCommerce\Internal\Admin\Notes\MobileApp;
use Automattic\WooCommerce\Internal\Admin\Notes\NewSalesRecord;
use Automattic\WooCommerce\Internal\Admin\Notes\OnboardingPayments;
use Automattic\WooCommerce\Internal\Admin\Notes\OnlineClothingStore;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsMoreInfoNeeded;
use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsRemindMeLater;
use Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile;
use Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore;
use Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts;
use Automattic\WooCommerce\Internal\Admin\Notes\ScheduledUpdatesPromotion;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\UnsecuredReportFiles;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommerceSubscriptions;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaySuggestionsDataSourcePoller;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\RemoteFreeExtensionsDataSourcePoller;
/**
* Events Class.
*/
class Events {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Array of note class to be added or updated.
*
* @var array
*/
private static $note_classes_to_added_or_updated = array(
CustomizeStoreWithBlocks::class,
CustomizingProductCatalog::class,
EditProductsOnTheMove::class,
EmailImprovements::class,
EUVATNumber::class,
FirstProduct::class,
LaunchChecklist::class,
MagentoMigration::class,
ManageOrdersOnTheGo::class,
MarketingJetpack::class,
MigrateFromShopify::class,
MobileApp::class,
NewSalesRecord::class,
OnboardingPayments::class,
OnlineClothingStore::class,
PaymentsMoreInfoNeeded::class,
PaymentsRemindMeLater::class,
PerformanceOnMobile::class,
PersonalizeStore::class,
RealTimeOrderAlerts::class,
ScheduledUpdatesPromotion::class,
TrackingOptIn::class,
WooCommercePayments::class,
WooCommerceSubscriptions::class,
);
/**
* The other note classes that are added in other places.
*
* @var array
*/
private static $other_note_classes = array(
InstallJPAndWCSPlugins::class,
OrderMilestones::class,
SellingOnlineCourses::class,
UnsecuredReportFiles::class,
WooSubscriptionsNotes::class,
);
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Cron event handlers.
*/
public function init() {
add_action( 'wc_admin_daily', array( $this, 'do_wc_admin_daily' ) );
add_filter( 'woocommerce_get_note_from_db', array( $this, 'get_note_from_db' ), 10, 1 );
// Initialize the WC_Notes_Refund_Returns Note to attach hook.
\WC_Notes_Refund_Returns::init();
}
/**
* Daily events to run.
*
* Note: Order_Milestones::possibly_add_note is hooked to this as well.
*/
public function do_wc_admin_daily() {
$this->possibly_add_notes();
$this->possibly_delete_notes();
$this->possibly_update_notes();
$this->possibly_refresh_data_source_pollers();
if ( $this->is_remote_inbox_notifications_enabled() ) {
RemoteInboxNotificationsDataSourcePoller::get_instance()->read_specs_from_data_sources();
RemoteInboxNotificationsEngine::run();
}
if ( Features::is_enabled( 'core-profiler' ) ) {
( new MailchimpScheduler() )->run();
}
}
/**
* Get note.
*
* @param Note $note_from_db The note object from the database.
*/
public function get_note_from_db( $note_from_db ) {
if ( ! $note_from_db instanceof Note || get_user_locale() === $note_from_db->get_locale() ) {
return $note_from_db;
}
$note_classes = array_merge( self::$note_classes_to_added_or_updated, self::$other_note_classes );
foreach ( $note_classes as $note_class ) {
if ( defined( "$note_class::NOTE_NAME" ) && $note_class::NOTE_NAME === $note_from_db->get_name() ) {
$note_from_class = method_exists( $note_class, 'get_note' ) ? $note_class::get_note() : null;
if ( $note_from_class instanceof Note ) {
$note = clone $note_from_db;
$note->set_title( $note_from_class->get_title() );
$note->set_content( $note_from_class->get_content() );
$actions = $note_from_class->get_actions();
foreach ( $actions as $action ) {
$matching_action = $note->get_action( $action->name );
if ( $matching_action && $matching_action->id ) {
$action->id = $matching_action->id;
}
}
$note->set_actions( $actions );
return $note;
}
break;
}
}
return $note_from_db;
}
/**
* Adds notes that should be added.
*/
protected function possibly_add_notes() {
foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
if ( method_exists( $note_class, 'possibly_add_note' ) ) {
$note_class::possibly_add_note();
}
}
}
/**
* Deletes notes that should be deleted.
*/
protected function possibly_delete_notes() {
PaymentsRemindMeLater::delete_if_not_applicable();
PaymentsMoreInfoNeeded::delete_if_not_applicable();
}
/**
* Updates notes that should be updated.
*/
protected function possibly_update_notes() {
foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
if ( method_exists( $note_class, 'possibly_update_note' ) ) {
$note_class::possibly_update_note();
}
}
}
/**
* Checks if remote inbox notifications are enabled.
*
* @return bool Whether remote inbox notifications are enabled.
*/
protected function is_remote_inbox_notifications_enabled() {
// Check if the feature flag is disabled.
if ( ! Features::is_enabled( 'remote-inbox-notifications' ) ) {
return false;
}
// Check if the site has opted out of marketplace suggestions.
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) !== 'yes' ) {
return false;
}
// All checks have passed.
return true;
}
/**
* Checks if merchant email notifications are enabled.
*
* @return bool Whether merchant email notifications are enabled.
*/
protected function is_merchant_email_notifications_enabled() {
// Check if the feature flag is disabled.
if ( get_option( 'woocommerce_merchant_email_notifications', 'no' ) !== 'yes' ) {
return false;
}
// All checks have passed.
return true;
}
/**
* Refresh transient for the following DataSourcePollers on wc_admin_daily cron job.
* - PaymentGatewaySuggestionsDataSourcePoller
* - RemoteFreeExtensionsDataSourcePoller
*/
protected function possibly_refresh_data_source_pollers() {
$completed_tasks = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );
if ( ! in_array( 'payments', $completed_tasks, true ) && ! in_array( 'woocommerce-payments', $completed_tasks, true ) ) {
PaymentGatewaySuggestionsDataSourcePoller::get_instance()->read_specs_from_data_sources();
}
if ( ! in_array( 'store_details', $completed_tasks, true ) && ! in_array( 'marketing', $completed_tasks, true ) ) {
RemoteFreeExtensionsDataSourcePoller::get_instance()->read_specs_from_data_sources();
}
}
}

View File

@@ -0,0 +1,246 @@
<?php
/**
* WooCommerce Admin: Feature plugin main class.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Internal\Admin\Notes\ScheduledUpdatesPromotion;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\PluginsInstaller;
use Automattic\WooCommerce\Admin\ReportExporter;
use Automattic\WooCommerce\Admin\ReportsSync;
use Automattic\WooCommerce\Internal\Admin\CategoryLookup;
use Automattic\WooCommerce\Internal\Admin\Events;
use Automattic\WooCommerce\Internal\Admin\Onboarding\Onboarding;
/**
* Feature plugin main class.
*
* @internal This file will not be bundled with woo core, only the feature plugin.
* @internal Note this is not called WC_Admin due to a class already existing in core with that name.
*/
class FeaturePlugin {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Indicates if init has been invoked already.
*
* @var bool
*/
private bool $initialized = false;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init the feature plugin, only if we can detect both Gutenberg and WooCommerce.
*/
public function init() {
// Bail if WC isn't initialized (This can be called from WCAdmin's entrypoint).
if ( ! defined( 'WC_ABSPATH' ) ) {
return;
}
if ( $this->initialized ) {
return;
}
$this->initialized = true;
// Load the page controller functions file first to prevent fatal errors when disabling WooCommerce Admin.
$this->define_constants();
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/page-controller-functions.php';
require_once WC_ADMIN_ABSPATH . '/src/Admin/Notes/DeprecatedNotes.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/core-functions.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/feature-config.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/wc-admin-update-functions.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/class-experimental-abtest.php';
if ( did_action( 'plugins_loaded' ) ) {
self::on_plugins_loaded();
} else {
// Make sure we hook into `plugins_loaded` before core's Automattic\WooCommerce\Package::init().
// If core is network activated but we aren't, the packaged version of WooCommerce Admin will
// attempt to use a data store that hasn't been loaded yet - because we've defined our constants here.
// See: https://github.com/woocommerce/woocommerce-admin/issues/3869.
add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ), 9 );
}
}
/**
* Setup plugin once all other plugins are loaded.
*
* @return void
*/
public function on_plugins_loaded() {
$this->hooks();
$this->includes();
}
/**
* Define Constants.
*/
protected function define_constants() {
$this->define( 'WC_ADMIN_APP', 'wc-admin-app' );
$this->define( 'WC_ADMIN_ABSPATH', WC_ABSPATH );
$this->define( 'WC_ADMIN_DIST_JS_FOLDER', 'assets/client/admin/' );
$this->define( 'WC_ADMIN_DIST_CSS_FOLDER', 'assets/client/admin/' );
$this->define( 'WC_ADMIN_PLUGIN_FILE', WC_PLUGIN_FILE );
/**
* Define the WC Admin Images Folder URL.
*
* @deprecated 6.7.0
* @var string
*/
if ( ! defined( 'WC_ADMIN_IMAGES_FOLDER_URL' ) ) {
/**
* Define the WC Admin Images Folder URL.
*
* @deprecated 6.7.0
* @var string
*/
define( 'WC_ADMIN_IMAGES_FOLDER_URL', plugins_url( 'assets/images', WC_PLUGIN_FILE ) );
}
/**
* Define the current WC Admin version.
*
* @deprecated 6.4.0
* @var string
*/
if ( ! defined( 'WC_ADMIN_VERSION_NUMBER' ) ) {
/**
* Define the current WC Admin version.
*
* @deprecated 6.4.0
* @var string
*/
define( 'WC_ADMIN_VERSION_NUMBER', '3.3.0' );
}
}
/**
* Include WC Admin classes.
*/
public function includes() {
// Initialize Database updates, option migrations, and Notes.
Events::instance()->init();
Notes::init();
// Initialize Plugins Installer.
PluginsInstaller::init();
PluginsHelper::init();
// Initialize API.
API\Init::instance();
if ( Features::is_enabled( 'onboarding' ) ) {
Onboarding::init();
}
if ( Features::is_enabled( 'analytics' ) ) {
// Initialize Reports syncing.
ReportsSync::init();
CategoryLookup::instance()->init();
// Initialize Reports exporter.
ReportExporter::init();
}
// Admin note providers.
// @todo These should be bundled in the features/ folder, but loading them from there currently has a load order issue.
new WooSubscriptionsNotes();
new OrderMilestones();
new TrackingOptIn();
new WooCommercePayments();
new InstallJPAndWCSPlugins();
new SellingOnlineCourses();
new MagentoMigration();
new ScheduledUpdatesPromotion();
}
/**
* Set up our admin hooks and plugin loader.
*/
protected function hooks() {
add_filter( 'woocommerce_admin_features', array( $this, 'replace_supported_features' ), 0 );
Loader::get_instance();
WCAdminAssets::get_instance();
}
/**
* Overwrites the allowed features array using a local `feature-config.php` file.
*
* @param array $features Array of feature slugs.
*/
public function replace_supported_features( $features ) {
/**
* Get additional feature config
*
* @since 6.5.0
*/
$feature_config = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
$features = array_keys( array_filter( $feature_config ) );
return $features;
}
/**
* Define constant if not already set.
*
* @param string $name Constant name.
* @param string|bool $value Constant value.
*/
protected function define( $name, $value ) {
if ( ! defined( $name ) ) {
define( $name, $value );
}
}
/**
* Prevent cloning.
*/
private function __clone() {}
/**
* Prevent unserializing.
*/
public function __wakeup() {
die();
}
}

View File

@@ -0,0 +1,280 @@
<?php
/**
* WooCommerce Homescreen.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Shipping;
/**
* Contains backend logic for the homescreen feature.
*/
class Homescreen {
/**
* Menu slug.
*/
const MENU_SLUG = 'wc-admin';
/**
* Class instance.
*
* @var Homescreen instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_menu', array( $this, 'register_page' ) );
// In WC Core 5.1 $submenu manipulation occurs in admin_menu, not admin_head. See https://github.com/woocommerce/woocommerce/pull/29088.
if ( version_compare( WC_VERSION, '5.1', '>=' ) ) {
// priority is 20 to run after admin_menu hook for woocommerce runs, so that submenu is populated.
add_action( 'admin_menu', array( $this, 'possibly_remove_woocommerce_menu' ) );
add_action( 'admin_menu', array( $this, 'update_link_structure' ), 20 );
} else {
// priority is 20 to run after https://github.com/woocommerce/woocommerce/blob/a55ae325306fc2179149ba9b97e66f32f84fdd9c/includes/admin/class-wc-admin-menus.php#L165.
add_action( 'admin_head', array( $this, 'update_link_structure' ), 20 );
}
add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) );
if ( Features::is_enabled( 'shipping-smart-defaults' ) ) {
add_filter(
'woocommerce_admin_shared_settings',
array( $this, 'maybe_set_default_shipping_options_on_home' ),
9999
);
}
}
/**
* Set free shipping in the same country as the store default
* Flag rate in all other countries when any of the following conditions are true
*
* - The store sells physical products, has JP and WCS installed and connected, and is located in the US.
* - The store sells physical products, and is not located in US/Canada/Australia/UK (irrelevant if JP is installed or not).
* - The store sells physical products and is located in US, but JP and WCS are not installed.
*
* @param array $settings shared admin settings.
* @return array
*/
public function maybe_set_default_shipping_options_on_home( $settings ) {
if ( ! function_exists( 'get_current_screen' ) ) {
return $settings;
}
$current_screen = get_current_screen();
// Abort if it's not the homescreen.
if ( ! isset( $current_screen->id ) || 'woocommerce_page_wc-admin' !== $current_screen->id ) {
return $settings;
}
// Abort if we already created the shipping options.
$already_created = get_option( 'woocommerce_admin_created_default_shipping_zones' );
if ( $already_created === 'yes' ) {
return $settings;
}
$zone_count = count( \WC_Data_Store::load( 'shipping-zone' )->get_zones() );
if ( $zone_count ) {
update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
update_option( 'woocommerce_admin_reviewed_default_shipping_zones', 'yes' );
return $settings;
}
$user_skipped_obw = $settings['onboarding']['profile']['skipped'] ?? false;
$store_address = $settings['preloadSettings']['general']['woocommerce_store_address'] ?? '';
$product_types = $settings['onboarding']['profile']['product_types'] ?? array();
$user_has_set_store_country = $settings['onboarding']['profile']['is_store_country_set'] ?? false;
// Do not proceed if user has not filled out their country in the onboarding profiler.
if ( ! $user_has_set_store_country ) {
return $settings;
}
// If user skipped the obw or has not completed the store_details
// then we assume the user is going to sell physical products.
if ( $user_skipped_obw || '' === $store_address ) {
$product_types[] = 'physical';
}
if ( false === in_array( 'physical', $product_types, true ) ) {
return $settings;
}
$country_code = wc_format_country_state_string( $settings['preloadSettings']['general']['woocommerce_default_country'] )['country'];
$country_name = WC()->countries->get_countries()[ $country_code ] ?? null;
$is_jetpack_installed = in_array( 'jetpack', $settings['plugins']['installedPlugins'] ?? array(), true );
$is_wcs_installed = in_array( 'woocommerce-services', $settings['plugins']['installedPlugins'] ?? array(), true );
if (
( 'US' === $country_code && $is_jetpack_installed )
||
( ! in_array( $country_code, array( 'CA', 'AU', 'NZ', 'SG', 'HK', 'GB', 'ES', 'IT', 'DE', 'FR', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true ) )
||
( 'US' === $country_code && false === $is_jetpack_installed && false === $is_wcs_installed )
) {
$zone = new \WC_Shipping_Zone();
$zone->set_zone_name( $country_name );
$zone->add_location( $country_code, 'country' );
// Method creation has no default title, use the REST API to add a title.
$instance_id = $zone->add_shipping_method( 'free_shipping' );
$request = new \WP_REST_Request( 'POST', '/wc/v2/shipping/zones/' . $zone->get_id() . '/methods/' . $instance_id );
$request->set_body_params(
array(
'settings' => array(
'title' => 'Free shipping',
),
)
);
rest_do_request( $request );
update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
Shipping::delete_zone_count_transient();
}
return $settings;
}
/**
* Adds fields so that we can store performance indicators, row settings, and chart type settings for users.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'homepage_layout',
'homepage_stats',
'task_list_tracked_started_tasks',
)
);
}
/**
* Registers home page.
*/
public function register_page() {
// Register a top-level item for users who cannot view the core WooCommerce menu.
if ( ! self::is_admin_user() ) {
wc_admin_register_page(
array(
'id' => 'woocommerce-home',
'title' => __( 'WooCommerce', 'woocommerce' ),
'path' => self::MENU_SLUG,
'capability' => 'read',
)
);
return;
}
wc_admin_register_page(
array(
'id' => 'woocommerce-home',
'title' => __( 'Home', 'woocommerce' ),
'parent' => 'woocommerce',
'path' => self::MENU_SLUG,
'order' => 0,
'capability' => 'read',
)
);
}
/**
* Check if the user can access the top-level WooCommerce item.
*
* @return bool
*/
public static function is_admin_user() {
if ( ! class_exists( 'WC_Admin_Menus', false ) ) {
include_once WC_ABSPATH . 'includes/admin/class-wc-admin-menus.php';
}
if ( method_exists( 'WC_Admin_Menus', 'can_view_woocommerce_menu_item' ) ) {
return \WC_Admin_Menus::can_view_woocommerce_menu_item() || current_user_can( 'manage_woocommerce' );
} else {
// We leave this line for WC versions <= 6.2.
return current_user_can( 'edit_others_shop_orders' ) || current_user_can( 'manage_woocommerce' );
}
}
/**
* Possibly remove the WooCommerce menu item if it was purely used to access wc-admin pages.
*/
public function possibly_remove_woocommerce_menu() {
global $menu;
if ( self::is_admin_user() ) {
return;
}
foreach ( $menu as $key => $menu_item ) {
if ( self::MENU_SLUG !== $menu_item[2] || 'read' !== $menu_item[1] ) {
continue;
}
unset( $menu[ $key ] );
}
}
/**
* Update the WooCommerce menu structure to make our main dashboard/handler
* the top level link for 'WooCommerce'.
*/
public function update_link_structure() {
global $submenu;
// User does not have capabilities to see the submenu.
if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) {
return;
}
$wc_admin_key = null;
foreach ( $submenu['woocommerce'] as $submenu_key => $submenu_item ) {
if ( self::MENU_SLUG === $submenu_item[2] ) {
$wc_admin_key = $submenu_key;
break;
}
}
if ( ! $wc_admin_key ) {
return;
}
$menu = $submenu['woocommerce'][ $wc_admin_key ];
// Move menu item to top of array.
unset( $submenu['woocommerce'][ $wc_admin_key ] );
array_unshift( $submenu['woocommerce'], $menu );
}
/**
* Preload options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_options( $options ) {
$options[] = 'woocommerce_default_homepage_layout';
$options[] = 'woocommerce_admin_install_timestamp';
return $options;
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\ImportExport;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
/**
* Helper for CSV import functionality.
*
* @since 9.3.0
*/
class CSVUploadHelper {
/**
* Name (inside the uploads folder) to use for the CSV import directory.
*
* @return string
*/
protected function get_import_subdir_name(): string {
return 'wc-imports';
}
/**
* Returns the full path to the CSV import directory within the uploads folder.
* It will attempt to create the directory if it doesn't exist.
*
* @param bool $create TRUE to attempt to create the directory. FALSE otherwise.
* @return string
* @throws \Exception In case the upload directory doesn't exits or can't be created.
*/
public function get_import_dir( bool $create = true ): string {
$wp_upload_dir = wp_upload_dir( null, $create );
if ( $wp_upload_dir['error'] ) {
throw new \Exception( esc_html( $wp_upload_dir['error'] ) );
}
$upload_dir = trailingslashit( $wp_upload_dir['basedir'] ) . $this->get_import_subdir_name();
if ( $create ) {
FilesystemUtil::mkdir_p_not_indexable( $upload_dir );
}
return $upload_dir;
}
/**
* Handles a CSV file upload.
*
* @param string $import_type Type of upload or context.
* @param string $files_index $_FILES index that contains the file to upload.
* @param array|null $allowed_mime_types List of allowed MIME types.
* @return array {
* Details for the uploaded file.
*
* @type int $id Attachment ID.
* @type string $file Full path to uploaded file.
* }
*
* @throws \Exception In case of error.
*/
public function handle_csv_upload( string $import_type, string $files_index = 'import', ?array $allowed_mime_types = null ): array {
$import_type = sanitize_key( $import_type );
if ( ! $import_type ) {
throw new \Exception( 'Import type is invalid.' );
}
if ( ! $allowed_mime_types ) {
$allowed_mime_types = array(
'csv' => 'text/csv',
'txt' => 'text/plain',
);
}
$file = $_FILES[ $files_index ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing
if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
throw new \Exception( esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) );
}
if ( ! function_exists( 'wp_import_handle_upload' ) ) {
require_once ABSPATH . 'wp-admin/includes/import.php';
}
// Make sure upload dir exists.
$this->get_import_dir();
// Add prefix.
$file['name'] = $import_type . '-' . $file['name'];
$overrides_callback = function ( $overrides_ ) use ( $allowed_mime_types ) {
$overrides_['test_form'] = false;
$overrides_['test_type'] = true;
$overrides_['mimes'] = $allowed_mime_types;
return $overrides_;
};
add_filter( 'upload_dir', array( $this, 'override_upload_dir' ) );
add_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0, 2 );
add_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 );
add_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 );
add_filter( 'wp_check_filetype_and_ext', array( $this, 'filter_woocommerce_check_filetype_for_csv' ), 10, 5 );
$orig_files_import = $_FILES['import'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing
$_FILES['import'] = $file; // wp_import_handle_upload() expects the file to be in 'import'.
$upload = wp_import_handle_upload();
remove_filter( 'upload_dir', array( $this, 'override_upload_dir' ) );
remove_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0 );
remove_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 );
remove_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 );
remove_filter( 'wp_check_filetype_and_ext', array( $this, 'filter_woocommerce_check_filetype_for_csv' ), 10 );
if ( $orig_files_import ) {
$_FILES['import'] = $orig_files_import;
} else {
unset( $_FILES['import'] );
}
if ( ! empty( $upload['error'] ) ) {
throw new \Exception( esc_html( $upload['error'] ) );
}
if ( ! wc_is_file_valid_csv( $upload['file'], false ) ) {
wp_delete_attachment( $file['id'], true );
throw new \Exception( esc_html__( 'Invalid file type for a CSV import.', 'woocommerce' ) );
}
return $upload;
}
/**
* Hooked onto 'upload_dir' to override the default upload directory for a CSV upload.
*
* @param array $uploads WP upload dir details.
* @return array
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function override_upload_dir( $uploads ): array {
$new_subdir = '/' . $this->get_import_subdir_name();
$uploads['path'] = $uploads['basedir'] . $new_subdir;
$uploads['url'] = $uploads['baseurl'] . $new_subdir;
$uploads['subdir'] = $new_subdir;
return $uploads;
}
/**
* Adds a random string to the name of an uploaded CSV file to make it less discoverable. Hooked onto 'wp_unique_filename'.
*
* @param string $filename File name.
* @param string $ext File extension.
* @return string
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function override_unique_filename( string $filename, string $ext ): string {
$length = min( 10, 255 - strlen( $filename ) - 1 );
if ( 1 < $length ) {
$suffix = strtolower( wp_generate_password( $length, false, false ) );
$filename = substr( $filename, 0, strlen( $filename ) - strlen( $ext ) ) . '-' . $suffix . $ext;
}
return $filename;
}
/**
* `wp_import_handle_upload()` appends .txt to any file name. This function is hooked onto 'wp_handle_upload_prefilter'
* to remove those extra characters.
*
* @param array $file File details in the form of a $_FILES entry.
* @return array Modified file details.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function remove_txt_from_uploaded_file( array $file ): array {
$file['name'] = substr( $file['name'], 0, -4 );
return $file;
}
/**
* Filters the WordPress determination of a file's type and extension, specifically to correct
* CSV files that are misidentified as 'text/html'.
*
* @param array $data An array of file data: ['ext'] (string), ['type'] (string), ['proper_filename'] (string|false).
* @param string $file Full path to the file.
* @param string $filename The Mime type of the file.
* @param array $mimes Array of mime types.
* @param string $real_mime The actual mime type or empty string.
* @return array Filtered file data.
*/
public function filter_woocommerce_check_filetype_for_csv( $data, $file, $filename, $mimes, $real_mime ) {
// Check if the file was misidentified as 'text/html' by PHP.
if ( 'text/html' === $real_mime ) {
// Determine the expected file type based on the filename extension.
// $mimes here is the context-specific list of mimes for the current upload.
$filename_check = wp_check_filetype( $filename, $mimes );
$file_ext = $filename_check['ext'];
$file_type = $filename_check['type'];
if ( ( 'csv' === $file_ext && 'text/csv' === $file_type ) ) {
$data['ext'] = 'csv';
$data['type'] = 'text/csv';
}
}
return $data;
}
}

View File

@@ -0,0 +1,577 @@
<?php
/**
* Register the scripts, styles, and includes needed for pieces of the WooCommerce Admin experience.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
/**
* Loader Class.
*/
class Loader {
/**
* Class instance.
*
* @var Loader instance
*/
protected static $instance = null;
/**
* An array of classes to load from the includes folder.
*
* @var array
*/
protected static $classes = array();
/**
* WordPress capability required to use analytics features.
*
* @var string
*/
protected static $required_capability = null;
/**
* An array of dependencies that have been preloaded (to avoid duplicates).
*
* @var array
*/
protected $preloaded_dependencies = array(
'script' => array(),
'style' => array(),
);
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
Features::get_instance();
WCAdminSharedSettings::get_instance();
Translations::get_instance();
WCAdminUser::get_instance();
Settings::get_instance();
SiteHealth::get_instance();
SystemStatusReport::get_instance();
wc_get_container()->get( Reviews::class );
wc_get_container()->get( ReviewsCommentsOverrides::class );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
add_filter( 'admin_title', array( __CLASS__, 'update_admin_title' ) );
add_action( 'in_admin_header', array( __CLASS__, 'embed_page_header' ) );
add_action( 'admin_head', array( __CLASS__, 'remove_notices' ) );
add_action( 'admin_head', array( __CLASS__, 'smart_app_banner' ) );
add_action( 'admin_notices', array( __CLASS__, 'inject_before_notices' ), -9999 );
add_action( 'admin_notices', array( __CLASS__, 'inject_after_notices' ), PHP_INT_MAX );
// Added this hook to delete the field woocommerce_onboarding_homepage_post_id when deleting the homepage.
add_action( 'trashed_post', array( __CLASS__, 'delete_homepage' ) );
/*
* Remove the emoji script as it always defaults to replacing emojis with Twemoji images.
* Gutenberg has also disabled emojis. More on that here -> https://github.com/WordPress/gutenberg/pull/6151
*/
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
add_action( 'load-themes.php', array( __CLASS__, 'add_appearance_theme_view_tracks_event' ) );
}
/**
* Returns breadcrumbs for the current page.
*/
private static function get_embed_breadcrumbs() {
return wc_admin_get_breadcrumbs();
}
/**
* Outputs breadcrumbs via PHP for the initial load of an embedded page.
*
* @param array $section Section to create breadcrumb from.
*/
private static function output_heading( $section ) {
echo esc_html( $section );
}
/**
* Set up a div for the header embed to render into.
* The initial contents here are meant as a place loader for when the PHP page initially loads.
*/
public static function embed_page_header() {
if ( ! PageController::is_admin_page() && ! PageController::is_embed_page() ) {
return;
}
if ( ! PageController::is_embed_page() ) {
return;
}
if ( PageController::is_modern_settings_page() ) {
return;
}
$sections = self::get_embed_breadcrumbs();
$sections = is_array( $sections ) ? $sections : array( $sections );
$page_title = '';
$pages_with_tabs = array(
'admin.php?page=wc-settings',
'admin.php?page=wc-reports',
'admin.php?page=wc-status',
);
if (
count( $sections ) > 2 &&
is_array( $sections[1] ) &&
in_array( $sections[1][0], $pages_with_tabs, true )
) {
$page_title = $sections[1][1];
} else {
$page_title = end( $sections );
}
?>
<div id="woocommerce-embedded-root" class="is-embed-loading">
<div class="woocommerce-layout">
<div class="woocommerce-layout__header is-embed-loading">
<h1 class="woocommerce-layout__header-heading">
<?php self::output_heading( $page_title ); ?>
</h1>
</div>
</div>
</div>
<?php
}
/**
* Adds body classes to the main wp-admin wrapper, allowing us to better target elements in specific scenarios.
*
* @param string $admin_body_class Body class to add.
*/
public static function add_admin_body_classes( $admin_body_class = '' ) {
if ( ! PageController::is_admin_or_embed_page() || PageController::is_modern_settings_page() ) {
return $admin_body_class;
}
$classes = explode( ' ', trim( $admin_body_class ) );
$classes[] = 'woocommerce-admin-page';
if ( PageController::is_embed_page() ) {
$classes[] = 'woocommerce-embed-page';
}
// Add page ID as a class.
$page_id = PageController::get_instance()->get_current_screen_id();
if ( $page_id ) {
$classes[] = $page_id;
}
/**
* Some routes or features like onboarding hide the wp-admin navigation and masterbar.
* Setting `woocommerce_admin_is_loading` to true allows us to premeptively hide these
* elements while the JS app loads.
* This class needs to be removed by those feature components (like <ProfileWizard />).
*
* @param bool $is_loading If WooCommerce Admin is loading a fullscreen view.
* @since 6.5.0
*/
$is_loading = apply_filters( 'woocommerce_admin_is_loading', false );
if ( PageController::is_admin_page() && $is_loading ) {
$classes[] = 'woocommerce-admin-is-loading';
}
$admin_body_class = implode( ' ', array_unique( $classes ) );
return " $admin_body_class ";
}
/**
* Adds an iOS "Smart App Banner" for display on iOS Safari.
* See https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
*/
public static function smart_app_banner() {
$exclude_paths = array(
'/customize-store',
'/setup-wizard',
'/launch-your-store',
);
/* phpcs:ignore */
$path = $_GET['path'] ?? '';
if ( PageController::is_admin_or_embed_page() && ! in_array( $path, $exclude_paths, true ) ) {
echo "
<meta name='apple-itunes-app' content='app-id=1389130815'>
";
}
}
/**
* Removes notices that should not be displayed on WC Admin pages.
*/
public static function remove_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Hello Dolly.
if ( function_exists( 'hello_dolly' ) ) {
remove_action( 'admin_notices', 'hello_dolly' );
}
}
/**
* Runs before admin notices action and hides them.
*/
public static function inject_before_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// The JITMs won't be shown in the Onboarding Wizard.
$is_onboarding = isset( $_GET['path'] ) && '/setup-wizard' === wc_clean( wp_unslash( $_GET['path'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
$maybe_hide_jitm = $is_onboarding ? '-hide' : '';
echo '<div class="woocommerce-layout__jitm' . sanitize_html_class( $maybe_hide_jitm ) . '" id="jp-admin-notices"></div>';
// Wrap the notices in a hidden div to prevent flickering before
// they are moved elsewhere in the page by WordPress Core.
echo '<div class="woocommerce-layout__notice-list-hide" id="wp__notice-list">';
if ( PageController::is_admin_page() ) {
// Capture all notices and hide them. WordPress Core looks for
// `.wp-header-end` and appends notices after it if found.
// https://github.com/WordPress/WordPress/blob/f6a37e7d39e2534d05b9e542045174498edfe536/wp-admin/js/common.js#L737 .
echo '<div class="wp-header-end" id="woocommerce-layout__notice-catcher"></div>';
}
}
/**
* Runs after admin notices and closes div.
*/
public static function inject_after_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Close the hidden div used to prevent notices from flickering before
// they are inserted elsewhere in the page.
echo '</div>';
}
/**
* Edits Admin title based on section of wc-admin.
*
* @param string $admin_title Modifies admin title.
* @todo Can we do some URL rewriting so we can figure out which page they are on server side?
*/
public static function update_admin_title( $admin_title ) {
if (
! did_action( 'current_screen' ) ||
! PageController::is_admin_page()
) {
return $admin_title;
}
$sections = self::get_embed_breadcrumbs();
$pieces = array();
foreach ( $sections as $section ) {
$pieces[] = is_array( $section ) ? $section[1] : $section;
}
$pieces = array_reverse( $pieces );
$title = implode( ' &lsaquo; ', $pieces );
/* translators: %1$s: updated title, %2$s: blog info name */
return sprintf( __( '%1$s &lsaquo; %2$s', 'woocommerce' ), $title, get_bloginfo( 'name' ) );
}
/**
* Set up a div for the app to render into.
*/
public static function page_wrapper() {
?>
<div class="wrap">
<div id="root"></div>
</div>
<?php
}
/**
* Hooks extra necessary data into the component settings array already set in WooCommerce core.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public static function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
if ( ! function_exists( 'wc_blocks_container' ) ) {
global $wp_locale;
// inject data not available via older versions of wc_blocks/woo.
$settings['orderStatuses'] = Settings::get_order_statuses( wc_get_order_statuses() );
$settings['stockStatuses'] = Settings::get_order_statuses( wc_get_product_stock_status_options() );
$settings['currency'] = Settings::get_currency_settings();
$settings['locale'] = array(
'siteLocale' => isset( $settings['siteLocale'] )
? $settings['siteLocale']
: get_locale(),
'userLocale' => isset( $settings['l10n']['userLocale'] )
? $settings['l10n']['userLocale']
: get_user_locale(),
'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
? $settings['l10n']['weekdaysShort']
: array_values( $wp_locale->weekday_abbrev ),
);
}
/**
* The woocommerce_component_settings_preload_endpoints filter
*
* @since 6.5.0
*/
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
'rest_preload_api_request'
);
}
/**
* The woocommerce_admin_preload_options filter
*
* @since 6.5.0
*/
$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
if ( ! empty( $preload_options ) ) {
foreach ( $preload_options as $option ) {
$settings['preloadOptions'][ $option ] = get_option( $option );
}
}
/**
* The woocommerce_admin_preload_settings filter
*
* @since 6.5.0
*/
$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
if ( ! empty( $preload_settings ) ) {
$setting_options = new \WC_REST_Setting_Options_V2_Controller();
foreach ( $preload_settings as $group ) {
$group_settings = $setting_options->get_group_settings( $group );
$preload_settings = array();
foreach ( $group_settings as $option ) {
if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
$preload_settings[ $option['id'] ] = $option['value'];
}
}
$settings['preloadSettings'][ $group ] = $preload_settings;
}
}
$user_controller = new \WP_REST_Users_Controller();
$request = new \WP_REST_Request();
$request->set_query_params( array( 'context' => 'edit' ) );
$user_response = $user_controller->get_current_item( $request );
$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();
$settings['currentUserData'] = $current_user_data;
$settings['reviewsEnabled'] = get_option( 'woocommerce_enable_reviews' );
$settings['manageStock'] = get_option( 'woocommerce_manage_stock' );
$settings['commentModeration'] = get_option( 'comment_moderation' );
$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );
// @todo On merge, once plugin images are added to core WooCommerce, `wcAdminAssetUrl` can be retired,
// and `wcAssetUrl` can be used in its place throughout the codebase.
$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
$settings['wcVersion'] = WC_VERSION;
$settings['siteUrl'] = site_url();
$settings['shopUrl'] = get_permalink( wc_get_page_id( 'shop' ) );
$settings['homeUrl'] = home_url();
$settings['dateFormat'] = get_option( 'date_format' );
$settings['timeZone'] = wc_timezone_string();
$settings['plugins'] = array(
'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
'activePlugins' => Plugins::get_active_plugins(),
);
// Plugins that depend on changing the translation work on the server but not the client -
// WooCommerce Branding is an example of this - so pass through the translation of
// 'WooCommerce' to wcSettings.
$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );
// We may have synced orders with a now-unregistered status.
// E.g An extension that added statuses is now inactive or removed.
$settings['unregisteredOrderStatuses'] = self::get_unregistered_order_statuses();
// The separator used for attributes found in Variation titles.
/* phpcs:ignore */
$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );
if ( ! empty( $preload_data_endpoints ) ) {
$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
? $settings['dataEndpoints']
: array();
foreach ( $preload_data_endpoints as $key => $endpoint ) {
// Handle error case: rest_do_request() doesn't guarantee success.
if ( empty( $preload_data[ $endpoint ] ) ) {
$settings['dataEndpoints'][ $key ] = array();
} else {
$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
}
}
}
$settings = self::get_custom_settings( $settings );
if ( PageController::is_embed_page() ) {
$settings['embedBreadcrumbs'] = self::get_embed_breadcrumbs();
}
$settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions();
$settings['connectNonce'] = wp_create_nonce( 'connect' );
$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
return $settings;
}
/**
* Format order statuses by removing a leading 'wc-' if present.
*
* @param array $statuses Order statuses.
* @return array formatted statuses.
*
* @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
*/
public static function get_order_statuses( $statuses ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::get_order_statuses' );
return Settings::get_order_statuses( $statuses );
}
/**
* Get all order statuses present in analytics tables that aren't registered.
*
* @return array Unregistered order statuses.
*
* @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
*/
public static function get_unregistered_order_statuses() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0' );
$registered_statuses = wc_get_order_statuses();
$all_synced_statuses = OrdersDataStore::get_all_statuses();
$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
$formatted_status_keys = Settings::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
$formatted_statuses = array_keys( $formatted_status_keys );
return array_combine( $formatted_statuses, $formatted_statuses );
}
/**
* Register the admin settings for use in the WC REST API
*
* @param array $groups Array of setting groups.
* @return array
*
* @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
*/
public static function add_settings_group( $groups ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::add_settings_group' );
return Settings::get_instance()->add_settings_group( $groups );
}
/**
* Add WC Admin specific settings
*
* @param array $settings Array of settings in wc admin group.
* @return array
*
* @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
*/
public static function add_settings( $settings ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::add_settings' );
return Settings::get_instance()->add_settings( $settings );
}
/**
* Gets custom settings used for WC Admin.
*
* @param array $settings Array of settings to merge into.
* @return array
*
* @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
*/
public static function get_custom_settings( $settings ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0' );
$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
$wc_admin_group_settings = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
$settings['wcAdminSettings'] = array();
foreach ( $wc_admin_group_settings as $setting ) {
if ( ! empty( $setting['id'] ) ) {
$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
}
}
return $settings;
}
/**
* Return an object defining the currency options for the site's current currency
*
* @return array Settings for the current currency {
* Array of settings.
*
* @type string $code Currency code.
* @type string $precision Number of decimals.
* @type string $symbol Symbol for currency.
* }
*
* @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
*/
public static function get_currency_settings() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::get_currency_settings' );
return Settings::get_currency_settings();
}
/**
* Delete woocommerce_onboarding_homepage_post_id field when the homepage is deleted
*
* @param int $post_id The deleted post id.
*/
public static function delete_homepage( $post_id ) {
if ( 'page' !== get_post_type( $post_id ) ) {
return;
}
$homepage_id = intval( get_option( 'woocommerce_onboarding_homepage_post_id', false ) );
if ( $homepage_id === $post_id ) {
delete_option( 'woocommerce_onboarding_homepage_post_id' );
}
}
/**
* Adds the appearance_theme_view Tracks event.
*/
public static function add_appearance_theme_view_tracks_event() {
wc_admin_record_tracks_event( 'appearance_theme_view', array() );
}
}

View File

@@ -0,0 +1,535 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Exception;
/**
* File class.
*
* An object representation of a single log file.
*/
class File {
/**
* The absolute path of the file.
*
* @var string
*/
protected $path;
/**
* The source property of the file, derived from the filename.
*
* @var string
*/
protected $source = '';
/**
* The 0-based increment of the file, if it has been rotated. Derived from the filename. Can only be 0-9.
*
* @var int|null
*/
protected $rotation;
/**
* The date the file was created, as a Unix timestamp, derived from the filename.
*
* @var int
*/
protected $created = 0;
/**
* The hash property of the file, derived from the filename.
*
* @var string
*/
protected $hash = '';
/**
* The file's resource handle when it is open.
*
* @var resource
*/
protected $stream;
/**
* Class File
*
* @param string $path The absolute path of the file.
*/
public function __construct( $path ) {
$this->path = $path;
$this->ingest_path();
}
/**
* Make sure open streams are closed.
*/
public function __destruct() {
if ( is_resource( $this->stream ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
fclose( $this->stream );
}
}
/**
* Parse a path to a log file to determine if it uses the standard filename structure and various properties.
*
* This makes assumptions about the structure of the log file's name. Using `-` to separate the name into segments,
* if there are at least 5 segments, it assumes that the last segment is the hash, and the three segments before
* that make up the date when the file was created in YYYY-MM-DD format. Any segments left after that are the
* "source" that generated the log entries. If the filename doesn't have enough segments, it falls back to the
* source and the hash both being the entire filename, and using the inode change time as the creation date.
*
* Example:
* my-custom-plugin.2-2025-01-01-a1b2c3d4e5f.log
* | | | |
* 'my-custom-plugin' | '2025-01-01' |
* (source) | (created) |
* '2' 'a1b2c3d4e5f'
* (rotation) (hash)
*
* @param string $path The full path of the log file.
*
* @return array {
* @type string $dirname The directory structure containing the file. See pathinfo().
* @type string $basename The filename with extension. See pathinfo().
* @type string $extension The file extension. See pathinfo().
* @type string $filename The filename without extension. See pathinfo().
* @type string $source The source of the log entries contained in the file.
* @type int|null $rotation The 0-based incremental rotation marker, if the file has been rotated.
* Should only be a single digit.
* @type int $created The date the file was created, as a Unix timestamp.
* @type string $hash The hash suffix of the filename that protects from direct access.
* @type string $file_id The public ID of the log file (filename without the hash).
* }
*/
public static function parse_path( string $path ): array {
$defaults = array(
'dirname' => '',
'basename' => '',
'extension' => '',
'filename' => '',
'source' => '',
'rotation' => null,
'created' => 0,
'hash' => '',
'file_id' => '',
);
$parsed = array_merge( $defaults, pathinfo( $path ) );
$segments = explode( '-', $parsed['filename'] );
$timestamp = strtotime( implode( '-', array_slice( $segments, -4, 3 ) ) );
if ( count( $segments ) >= 5 && false !== $timestamp ) {
$parsed['source'] = implode( '-', array_slice( $segments, 0, -4 ) );
$parsed['created'] = $timestamp;
$parsed['hash'] = array_slice( $segments, -1 )[0];
} else {
$parsed['source'] = implode( '-', $segments );
}
$rotation_marker = strrpos( $parsed['source'], '.', -1 );
if ( false !== $rotation_marker ) {
$rotation = substr( $parsed['source'], -1 );
if ( is_numeric( $rotation ) ) {
$parsed['rotation'] = intval( $rotation );
}
$parsed['source'] = substr( $parsed['source'], 0, $rotation_marker );
}
$parsed['file_id'] = static::generate_file_id(
$parsed['source'],
$parsed['rotation'],
$parsed['created']
);
return $parsed;
}
/**
* Generate a public ID for a log file based on its properties.
*
* The file ID is the basename of the file without the hash part. It allows us to identify a file without revealing
* its full name in the filesystem, so that it's difficult to access the file directly with an HTTP request.
*
* @param string $source The source of the log entries contained in the file.
* @param int|null $rotation Optional. The 0-based incremental rotation marker, if the file has been rotated.
* Should only be a single digit.
* @param int $created Optional. The date the file was created, as a Unix timestamp.
*
* @return string
*/
public static function generate_file_id( string $source, ?int $rotation = null, int $created = 0 ): string {
$file_id = static::sanitize_source( $source );
if ( ! is_null( $rotation ) ) {
$file_id .= '.' . $rotation;
}
if ( $created > 0 ) {
$file_id .= '-' . gmdate( 'Y-m-d', $created );
}
return $file_id;
}
/**
* Generate a hash to use as the suffix on a log filename.
*
* @param string $file_id A file ID (file basename without the hash).
*
* @return string
*/
public static function generate_hash( string $file_id ): string {
$key = Constants::get_constant( 'AUTH_SALT' ) ?? 'wc-logs';
return hash_hmac( 'md5', $file_id, $key );
}
/**
* Sanitize the source property of a log file.
*
* @param string $source The source of the log entries contained in the file.
*
* @return string
*/
public static function sanitize_source( string $source ): string {
return sanitize_file_name( $source );
}
/**
* Parse the log file path and assign various properties to this class instance.
*
* @return void
*/
protected function ingest_path(): void {
$parsed_path = static::parse_path( $this->path );
$this->source = $parsed_path['source'];
$this->rotation = $parsed_path['rotation'];
$this->created = $parsed_path['created'];
$this->hash = $parsed_path['hash'];
}
/**
* Check if the filename structure is in the expected format.
*
* @see parse_path().
*
* @return bool
*/
public function has_standard_filename(): bool {
return ! ! $this->get_hash();
}
/**
* Check if the file represented by the class instance is a file and is readable.
*
* @return bool
*/
public function is_readable(): bool {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$is_readable = $filesystem->is_file( $this->path ) && $filesystem->is_readable( $this->path );
} catch ( Exception $exception ) {
return false;
}
return $is_readable;
}
/**
* Check if the file represented by the class instance is a file and is writable.
*
* @return bool
*/
public function is_writable(): bool {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$is_writable = $filesystem->is_file( $this->path ) && $filesystem->is_writable( $this->path );
} catch ( Exception $exception ) {
return false;
}
return $is_writable;
}
/**
* Open a read-only stream for this file.
*
* @return resource|false
*/
public function get_stream() {
if ( ! $this->is_readable() ) {
return false;
}
if ( ! is_resource( $this->stream ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
$this->stream = fopen( $this->path, 'rb' );
}
return $this->stream;
}
/**
* Close the stream for this file.
*
* The stream will also close automatically when the class instance destructs, but this can be useful for
* avoiding having a large number of streams open simultaneously.
*
* @return bool
*/
public function close_stream(): bool {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
return fclose( $this->stream );
}
/**
* Get the full absolute path of the file.
*
* @return string
*/
public function get_path(): string {
return $this->path;
}
/**
* Get the name of the file, with extension, but without full path.
*
* @return string
*/
public function get_basename(): string {
return basename( $this->path );
}
/**
* Get the file's source property.
*
* @return string
*/
public function get_source(): string {
return $this->source;
}
/**
* Get the file's rotation property.
*
* @return int|null
*/
public function get_rotation(): ?int {
return $this->rotation;
}
/**
* Get the file's hash property.
*
* @return string
*/
public function get_hash(): string {
return $this->hash;
}
/**
* Get the file's public ID.
*
* @return string
*/
public function get_file_id(): string {
$created = 0;
if ( $this->has_standard_filename() ) {
$created = $this->get_created_timestamp();
}
$file_id = static::generate_file_id(
$this->get_source(),
$this->get_rotation(),
$created
);
return $file_id;
}
/**
* Get the file's created property.
*
* @return int
*/
public function get_created_timestamp(): int {
if ( ! $this->created && $this->is_readable() ) {
$this->created = filectime( $this->path );
}
return $this->created;
}
/**
* Get the time of the last modification of the file, as a Unix timestamp. Or false if the file isn't readable.
*
* @return int|false
*/
public function get_modified_timestamp() {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$timestamp = $filesystem->mtime( $this->path );
} catch ( Exception $exception ) {
return false;
}
return $timestamp;
}
/**
* Get the size of the file in bytes. Or false if the file isn't readable.
*
* @return int|false
*/
public function get_file_size() {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
if ( ! $filesystem->is_readable( $this->path ) ) {
return false;
}
$size = $filesystem->size( $this->path );
} catch ( Exception $exception ) {
return false;
}
return $size;
}
/**
* Create and set permissions on the file.
*
* @return bool
*/
protected function create(): bool {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$created = $filesystem->touch( $this->path );
$modded = $filesystem->chmod( $this->path );
} catch ( Exception $exception ) {
return false;
}
return $created && $modded;
}
/**
* Write content to the file, appending it to the end.
*
* @param string $text The content to add to the file.
*
* @return bool
*/
public function write( string $text ): bool {
if ( '' === $text ) {
return false;
}
if ( ! $this->is_writable() ) {
$created = $this->create();
if ( ! $created || ! $this->is_writable() ) {
return false;
}
}
// Ensure content ends with a line ending.
$eol_pos = strrpos( $text, PHP_EOL );
if ( false === $eol_pos || strlen( $text ) !== $eol_pos + 1 ) {
$text .= PHP_EOL;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
$resource = fopen( $this->path, 'ab' );
mbstring_binary_safe_encoding();
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite -- No suitable alternative.
$bytes_written = fwrite( $resource, $text );
reset_mbstring_encoding();
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
fclose( $resource );
if ( strlen( $text ) !== $bytes_written ) {
return false;
}
return true;
}
/**
* Rename this file with an incremented rotation number.
*
* @return bool True if the file was successfully rotated.
*/
public function rotate(): bool {
if ( ! $this->is_writable() ) {
return false;
}
$created = 0;
if ( $this->has_standard_filename() ) {
$created = $this->get_created_timestamp();
}
if ( is_null( $this->get_rotation() ) ) {
$new_rotation = 0;
} else {
$new_rotation = $this->get_rotation() + 1;
}
$new_file_id = static::generate_file_id( $this->get_source(), $new_rotation, $created );
$search = array( $this->get_file_id() );
$replace = array( $new_file_id );
if ( $this->has_standard_filename() ) {
$search[] = $this->get_hash();
$replace[] = static::generate_hash( $new_file_id );
}
$old_filename = $this->get_basename();
$new_filename = str_replace( $search, $replace, $old_filename );
$new_path = str_replace( $old_filename, $new_filename, $this->path );
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$moved = $filesystem->move( $this->path, $new_path, true );
} catch ( Exception $exception ) {
return false;
}
if ( ! $moved ) {
return false;
}
$this->path = $new_path;
$this->ingest_path();
return $this->is_readable();
}
/**
* Delete the file from the filesystem.
*
* @return bool True on success, false on failure.
*/
public function delete(): bool {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$deleted = $filesystem->delete( $this->path, false, 'f' );
} catch ( Exception $exception ) {
return false;
}
return $deleted;
}
}

View File

@@ -0,0 +1,680 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\Settings;
use PclZip;
use WC_Cache_Helper;
use WP_Error;
/**
* FileController class.
*/
class FileController {
/**
* The maximum number of rotations for a file before they start getting overwritten.
*
* This number should not go above 10, or it will cause issues with the glob patterns.
*
* const int
*/
private const MAX_FILE_ROTATIONS = 10;
/**
* Default values for arguments for the get_files method.
*
* @const array
*/
public const DEFAULTS_GET_FILES = array(
'date_end' => 0,
'date_filter' => '',
'date_start' => 0,
'offset' => 0,
'order' => 'desc',
'orderby' => 'modified',
'per_page' => 20,
'source' => '',
);
/**
* Default values for arguments for the search_within_files method.
*
* @const array
*/
public const DEFAULTS_SEARCH_WITHIN_FILES = array(
'offset' => 0,
'per_page' => 50,
);
/**
* The maximum number of files that can be searched at one time.
*
* @const int
*/
public const SEARCH_MAX_FILES = 100;
/**
* The maximum number of search results that can be returned at one time.
*
* @const int
*/
public const SEARCH_MAX_RESULTS = 200;
/**
* The cache group name to use for caching operations.
*
* @const string
*/
private const CACHE_GROUP = 'log-files';
/**
* A cache key for storing and retrieving the results of the last logs search.
*
* @const string
*/
private const SEARCH_CACHE_KEY = 'logs_previous_search';
/**
* Get the file size limit that determines when to rotate a file.
*
* @return int
*/
private function get_file_size_limit(): int {
$default = 5 * MB_IN_BYTES;
/**
* Filter the threshold size of a log file at which point it will get rotated.
*
* @since 3.4.0
*
* @param int $file_size_limit The file size limit in bytes.
*/
$file_size_limit = apply_filters( 'woocommerce_log_file_size_limit', $default );
if ( ! is_int( $file_size_limit ) || $file_size_limit < 1 ) {
return $default;
}
return $file_size_limit;
}
/**
* Write a log entry to the appropriate file, after rotating the file if necessary.
*
* @param string $source The source property of the log entry, which determines which file to write to.
* @param string $text The contents of the log entry to add to a file.
* @param int|null $time Optional. The time of the log entry as a Unix timestamp. Defaults to the current time.
*
* @return bool True if the contents were successfully written to the file.
*/
public function write_to_file( string $source, string $text, ?int $time = null ): bool {
if ( is_null( $time ) ) {
$time = time();
}
$file_id = File::generate_file_id( $source, null, $time );
$file = $this->get_file_by_id( $file_id );
if ( $file instanceof File && $file->get_file_size() >= $this->get_file_size_limit() ) {
$rotated = $this->rotate_file( $file->get_file_id() );
if ( $rotated ) {
$file = null;
} else {
return false;
}
}
if ( ! $file instanceof File ) {
$new_path = Settings::get_log_directory() . $this->generate_filename( $source, $time );
$file = new File( $new_path );
}
return $file->write( $text );
}
/**
* Generate the full name of a file based on source and date values.
*
* @param string $source The source property of a log entry, which determines the filename.
* @param int $time The time of the log entry as a Unix timestamp.
*
* @return string
*/
private function generate_filename( string $source, int $time ): string {
$file_id = File::generate_file_id( $source, null, $time );
$hash = File::generate_hash( $file_id );
return "$file_id-$hash.log";
}
/**
* Get all the rotations of a file and increment them, so that they overwrite the previous file with that rotation.
*
* @param string $file_id A file ID (file basename without the hash).
*
* @return bool True if the file and all its rotations were successfully rotated.
*/
private function rotate_file( $file_id ): bool {
$rotations = $this->get_file_rotations( $file_id );
if ( is_wp_error( $rotations ) || ! isset( $rotations['current'] ) ) {
return false;
}
$max_rotation_marker = self::MAX_FILE_ROTATIONS - 1;
// Don't rotate a file with the maximum rotation.
unset( $rotations[ $max_rotation_marker ] );
$results = array();
// Rotate starting with oldest first and working backwards.
for ( $i = $max_rotation_marker; $i >= 0; $i -- ) {
if ( isset( $rotations[ $i ] ) ) {
$results[] = $rotations[ $i ]->rotate();
}
}
$results[] = $rotations['current']->rotate();
return ! in_array( false, $results, true );
}
/**
* Get an array of log files.
*
* @param array $args {
* Optional. Arguments to filter and sort the files that are returned.
*
* @type int $date_end The end of the date range to filter by, as a Unix timestamp.
* @type string $date_filter Filter files by one of the date props. 'created' or 'modified'.
* @type int $date_start The beginning of the date range to filter by, as a Unix timestamp.
* @type int $offset Omit this number of files from the beginning of the list. Works with $per_page to do pagination.
* @type string $order The sort direction. 'asc' or 'desc'. Defaults to 'desc'.
* @type string $orderby The property to sort the list by. 'created', 'modified', 'source', 'size'. Defaults to 'modified'.
* @type int $per_page The number of files to include in the list. Works with $offset to do pagination.
* @type string $source Only include files from this source.
* }
* @param bool $count_only Optional. True to return a total count of the files.
*
* @return File[]|int|WP_Error
*/
public function get_files( array $args = array(), bool $count_only = false ) {
$args = wp_parse_args( $args, self::DEFAULTS_GET_FILES );
$pattern = $args['source'] . '*.log';
$paths = glob( Settings::get_log_directory() . $pattern );
if ( false === $paths ) {
return new WP_Error(
'wc_log_directory_error',
__( 'Could not access the log file directory.', 'woocommerce' )
);
}
$files = $this->convert_paths_to_objects( $paths );
if ( $args['date_filter'] && $args['date_start'] && $args['date_end'] ) {
switch ( $args['date_filter'] ) {
case 'created':
$files = array_filter(
$files,
fn( $file ) => $file->get_created_timestamp() >= $args['date_start']
&& $file->get_created_timestamp() <= $args['date_end']
);
break;
case 'modified':
$files = array_filter(
$files,
fn( $file ) => $file->get_modified_timestamp() >= $args['date_start']
&& $file->get_modified_timestamp() <= $args['date_end']
);
break;
}
}
if ( true === $count_only ) {
return count( $files );
}
$multi_sorter = function( $sort_sets, $order_sets ) {
$comparison = 0;
while ( ! empty( $sort_sets ) ) {
$set = array_shift( $sort_sets );
$order = array_shift( $order_sets );
if ( 'desc' === $order ) {
$comparison = $set[1] <=> $set[0];
} else {
$comparison = $set[0] <=> $set[1];
}
if ( 0 !== $comparison ) {
break;
}
}
return $comparison;
};
switch ( $args['orderby'] ) {
case 'created':
$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
$sort_sets = array(
array( $a->get_created_timestamp(), $b->get_created_timestamp() ),
array( $a->get_source(), $b->get_source() ),
array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
);
$order_sets = array( $args['order'], 'asc', 'asc' );
return $multi_sorter( $sort_sets, $order_sets );
};
break;
case 'modified':
$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
$sort_sets = array(
array( $a->get_modified_timestamp(), $b->get_modified_timestamp() ),
array( $a->get_source(), $b->get_source() ),
array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
);
$order_sets = array( $args['order'], 'asc', 'asc' );
return $multi_sorter( $sort_sets, $order_sets );
};
break;
case 'source':
$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
$sort_sets = array(
array( $a->get_source(), $b->get_source() ),
array( $a->get_created_timestamp(), $b->get_created_timestamp() ),
array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
);
$order_sets = array( $args['order'], 'desc', 'asc' );
return $multi_sorter( $sort_sets, $order_sets );
};
break;
case 'size':
$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
$sort_sets = array(
array( $a->get_file_size(), $b->get_file_size() ),
array( $a->get_source(), $b->get_source() ),
array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
);
$order_sets = array( $args['order'], 'asc', 'asc' );
return $multi_sorter( $sort_sets, $order_sets );
};
break;
}
usort( $files, $sort_callback );
return array_slice( $files, $args['offset'], $args['per_page'] );
}
/**
* Get one or more File instances from an array of file IDs.
*
* @param array $file_ids An array of file IDs (file basename without the hash).
*
* @return File[]
*/
public function get_files_by_id( array $file_ids ): array {
$log_directory = Settings::get_log_directory();
$paths = array();
foreach ( $file_ids as $file_id ) {
// Look for the standard filename format first, which includes a hash.
$glob = glob( $log_directory . $file_id . '-*.log' );
if ( ! $glob ) {
$glob = glob( $log_directory . $file_id . '.log' );
}
if ( is_array( $glob ) ) {
$paths = array_merge( $paths, $glob );
}
}
$files = $this->convert_paths_to_objects( array_unique( $paths ) );
return $files;
}
/**
* Get a File instance from a file ID.
*
* @param string $file_id A file ID (file basename without the hash).
*
* @return File|WP_Error
*/
public function get_file_by_id( string $file_id ) {
$result = $this->get_files_by_id( array( $file_id ) );
if ( count( $result ) < 1 ) {
return new WP_Error(
'wc_log_file_error',
esc_html__( 'This file does not exist.', 'woocommerce' )
);
}
if ( count( $result ) > 1 ) {
return new WP_Error(
'wc_log_file_error',
esc_html__( 'Multiple files match this ID.', 'woocommerce' )
);
}
return reset( $result );
}
/**
* Get File instances for a given file ID and all of its related rotations.
*
* @param string $file_id A file ID (file basename without the hash).
*
* @return File[]|WP_Error An associative array where the rotation integer of the file is the key, and a "current"
* key for the iteration of the file that hasn't been rotated (if it exists).
*/
public function get_file_rotations( string $file_id ) {
$file = $this->get_file_by_id( $file_id );
if ( is_wp_error( $file ) ) {
return $file;
}
$current = array();
$rotations = array();
$source = $file->get_source();
$created = 0;
if ( $file->has_standard_filename() ) {
$created = $file->get_created_timestamp();
}
if ( is_null( $file->get_rotation() ) ) {
$current['current'] = $file;
} else {
$current_file_id = File::generate_file_id( $source, null, $created );
$result = $this->get_file_by_id( $current_file_id );
if ( ! is_wp_error( $result ) ) {
$current['current'] = $result;
}
}
$rotations_pattern = sprintf(
'.[%s]',
implode(
'',
range( 0, self::MAX_FILE_ROTATIONS - 1 )
)
);
$created_pattern = $created ? '-' . gmdate( 'Y-m-d', $created ) . '-' : '';
$rotation_pattern = Settings::get_log_directory() . $source . $rotations_pattern . $created_pattern . '*.log';
$rotation_paths = glob( $rotation_pattern );
$rotation_files = $this->convert_paths_to_objects( $rotation_paths );
foreach ( $rotation_files as $rotation_file ) {
if ( $rotation_file->is_readable() ) {
$rotations[ $rotation_file->get_rotation() ] = $rotation_file;
}
}
ksort( $rotations );
return array_merge( $current, $rotations );
}
/**
* Helper method to get an array of File instances.
*
* @param array $paths An array of absolute file paths.
*
* @return File[]
*/
private function convert_paths_to_objects( array $paths ): array {
$files = array_map(
function( $path ) {
$file = new File( $path );
return $file->is_readable() ? $file : null;
},
$paths
);
return array_filter( $files );
}
/**
* Get a list of sources for existing log files.
*
* @return array|WP_Error
*/
public function get_file_sources() {
$paths = glob( Settings::get_log_directory() . '*.log' );
if ( false === $paths ) {
return new WP_Error(
'wc_log_directory_error',
__( 'Could not access the log file directory.', 'woocommerce' )
);
}
$all_sources = array_map(
function( $path ) {
$file = new File( $path );
return $file->is_readable() ? $file->get_source() : null;
},
$paths
);
return array_unique( array_filter( $all_sources ) );
}
/**
* Delete one or more files from the filesystem.
*
* @param array $file_ids An array of file IDs (file basename without the hash).
*
* @return int The number of files that were deleted.
*/
public function delete_files( array $file_ids ): int {
$deleted = 0;
$files = $this->get_files_by_id( $file_ids );
foreach ( $files as $file ) {
$result = $file->delete();
if ( true === $result ) {
$deleted ++;
}
}
if ( $deleted > 0 ) {
$this->invalidate_cache();
}
return $deleted;
}
/**
* Stream a single file to the browser without zipping it first.
*
* @param string $file_id A file ID (file basename without the hash).
*
* @return WP_Error|void Only returns something if there is an error.
*/
public function export_single_file( $file_id ) {
$file = $this->get_file_by_id( $file_id );
if ( is_wp_error( $file ) ) {
return $file;
}
$file_name = $file->get_file_id() . '.log';
$exporter = new FileExporter( $file->get_path(), $file_name );
return $exporter->emit_file();
}
/**
* Create a zip archive of log files and stream it to the browser.
*
* @param array $file_ids An array of file IDs (file basename without the hash).
*
* @return WP_Error|void Only returns something if there is an error.
*/
public function export_multiple_files( array $file_ids ) {
$files = $this->get_files_by_id( $file_ids );
if ( count( $files ) < 1 ) {
return new WP_Error(
'wc_logs_invalid_file',
__( 'Could not access the specified files.', 'woocommerce' )
);
}
$temp_dir = get_temp_dir();
if ( ! is_dir( $temp_dir ) || ! wp_is_writable( $temp_dir ) ) {
return new WP_Error(
'wc_logs_invalid_directory',
__( 'Could not write to the temp directory. Try downloading files one at a time instead.', 'woocommerce' )
);
}
require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
$path = trailingslashit( $temp_dir ) . 'woocommerce_logs_' . gmdate( 'Y-m-d_H-i-s' ) . '.zip';
$file_paths = array_map(
fn( $file ) => $file->get_path(),
$files
);
$archive = new PclZip( $path );
$archive->create( $file_paths, PCLZIP_OPT_REMOVE_ALL_PATH );
$exporter = new FileExporter( $path );
return $exporter->emit_file();
}
/**
* Search within a set of log files for a particular string.
*
* @param string $search The string to search for.
* @param array $args Optional. Arguments for pagination of search results.
* @param array $file_args Optional. Arguments to filter and sort the files that are returned. See get_files().
* @param bool $count_only Optional. True to return a total count of the matches.
*
* @return array|int|WP_Error When matches are found, each array item is an associative array that includes the
* file ID, line number, and the matched string with HTML markup around the matched parts.
*/
public function search_within_files( string $search, array $args = array(), array $file_args = array(), bool $count_only = false ) {
if ( '' === $search ) {
return $count_only ? 0 : array();
}
$search = esc_html( $search );
$args = wp_parse_args( $args, self::DEFAULTS_SEARCH_WITHIN_FILES );
$file_args = array_merge(
$file_args,
array(
'offset' => 0,
'per_page' => self::SEARCH_MAX_FILES,
)
);
$cache_key = WC_Cache_Helper::get_prefixed_key( self::SEARCH_CACHE_KEY, self::CACHE_GROUP );
$query = wp_json_encode( array( $search, $args, $file_args ) );
$cache = wp_cache_get( $cache_key );
$is_cached = isset( $cache['query'], $cache['results'] ) && $query === $cache['query'];
if ( true === $is_cached ) {
$matched_lines = $cache['results'];
} else {
$files = $this->get_files( $file_args );
if ( is_wp_error( $files ) ) {
return $files;
}
// Max string size * SEARCH_MAX_RESULTS = ~1MB largest possible cache entry.
$max_string_size = 5 * KB_IN_BYTES;
$matched_lines = array();
foreach ( $files as $file ) {
$stream = $file->get_stream();
$line_number = 1;
while ( ! feof( $stream ) ) {
$line = fgets( $stream, $max_string_size );
if ( ! is_string( $line ) ) {
continue;
}
$sanitized_line = esc_html( trim( $line ) );
if ( false !== stripos( $sanitized_line, $search ) ) {
$matched_lines[] = array(
'file_id' => $file->get_file_id(),
'line_number' => $line_number,
'line' => $sanitized_line,
);
}
if ( count( $matched_lines ) >= self::SEARCH_MAX_RESULTS ) {
$file->close_stream();
break 2;
}
if ( false !== strstr( $line, PHP_EOL ) ) {
$line_number ++;
}
}
$file->close_stream();
}
$to_cache = array(
'query' => $query,
'results' => $matched_lines,
);
wp_cache_set( $cache_key, $to_cache, self::CACHE_GROUP, DAY_IN_SECONDS );
}
if ( true === $count_only ) {
return count( $matched_lines );
}
return array_slice( $matched_lines, $args['offset'], $args['per_page'] );
}
/**
* Calculate the size, in bytes, of the log directory.
*
* @return int
*/
public function get_log_directory_size(): int {
$bytes = 0;
$path = realpath( Settings::get_log_directory( false ) );
if ( wp_is_writable( $path ) ) {
$iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CATCH_GET_CHILD );
foreach ( $iterator as $file ) {
$bytes += $file->getSize();
}
}
return $bytes;
}
/**
* Invalidate the cache group related to log file data.
*
* @return bool True on successfully invalidating the cache.
*/
public function invalidate_cache(): bool {
return WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Exception;
use WP_Error;
/**
* FileExport class.
*/
class FileExporter {
/**
* The number of bytes per read while streaming the file.
*
* @const int
*/
private const CHUNK_SIZE = 4 * KB_IN_BYTES;
/**
* The absolute path of the file.
*
* @var string
*/
private $path;
/**
* A name of the file to send to the browser rather than the filename part of the path.
*
* @var string
*/
private $alternate_filename;
/**
* Class FileExporter.
*
* @param string $path The absolute path of the file.
* @param string $alternate_filename Optional. The name of the file to send to the browser rather than the filename
* part of the path.
*/
public function __construct( string $path, string $alternate_filename = '' ) {
$this->path = $path;
$this->alternate_filename = $alternate_filename;
}
/**
* Configure PHP and stream the file to the browser.
*
* @return WP_Error|void Only returns something if there is an error.
*/
public function emit_file() {
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$is_readable = $filesystem->is_file( $this->path ) && $filesystem->is_readable( $this->path );
} catch ( Exception $exception ) {
$is_readable = false;
}
if ( ! $is_readable ) {
return new WP_Error(
'wc_logs_invalid_file',
__( 'Could not access file.', 'woocommerce' )
);
}
// These configuration tweaks are copied from WC_CSV_Exporter::send_headers().
// phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
if ( function_exists( 'gc_enable' ) ) {
gc_enable(); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.gc_enableFound
}
if ( function_exists( 'apache_setenv' ) ) {
@apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv
}
@ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
@ini_set( 'output_buffering', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
@ini_set( 'output_handler', '' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
ignore_user_abort( true );
wc_set_time_limit();
wc_nocache_headers();
// phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged
$this->send_headers();
$this->send_contents();
die;
}
/**
* Send HTTP headers at the beginning of a file.
*
* Modeled on WC_CSV_Exporter::send_headers().
*
* @return void
*/
private function send_headers(): void {
header( 'Content-Type: text/plain; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=' . $this->get_filename() );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );
}
/**
* Send the contents of the file.
*
* @return void
*/
private function send_contents(): void {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- No suitable alternative.
$stream = fopen( $this->path, 'rb' );
while ( is_resource( $stream ) && ! feof( $stream ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- No suitable alternative.
$chunk = fread( $stream, self::CHUNK_SIZE );
if ( is_string( $chunk ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Outputting to file.
echo $chunk;
}
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- No suitable alternative.
fclose( $stream );
}
/**
* Get the name of the file that will be sent to the browser.
*
* @return string
*/
private function get_filename(): string {
if ( $this->alternate_filename ) {
return $this->alternate_filename;
}
return basename( $this->path );
}
}

View File

@@ -0,0 +1,334 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
use WP_List_Table;
/**
* FileListTable class.
*/
class FileListTable extends WP_List_Table {
/**
* The user option key for saving the preferred number of files displayed per page.
*
* @const string
*/
public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_file_list_per_page';
/**
* Instance of FileController.
*
* @var FileController
*/
private $file_controller;
/**
* Instance of PageController.
*
* @var PageController
*/
private $page_controller;
/**
* FileListTable class.
*
* @param FileController $file_controller Instance of FileController.
* @param PageController $page_controller Instance of PageController.
*/
public function __construct( FileController $file_controller, PageController $page_controller ) {
$this->file_controller = $file_controller;
$this->page_controller = $page_controller;
parent::__construct(
array(
'singular' => 'log-file',
'plural' => 'log-files',
'ajax' => false,
)
);
}
/**
* Render message when there are no items.
*
* @return void
*/
public function no_items(): void {
esc_html_e( 'No log files found.', 'woocommerce' );
}
/**
* Retrieves the list of bulk actions available for this table.
*
* @return array
*/
protected function get_bulk_actions(): array {
return array(
'export' => esc_html__( 'Download', 'woocommerce' ),
'delete' => esc_html__( 'Delete permanently', 'woocommerce' ),
);
}
/**
* Get the existing log sources for the filter dropdown.
*
* @return array
*/
protected function get_sources_list(): array {
$sources = $this->file_controller->get_file_sources();
if ( is_wp_error( $sources ) ) {
return array();
}
sort( $sources );
return $sources;
}
/**
* Displays extra controls between bulk actions and pagination.
*
* @param string $which The location of the tablenav being rendered. 'top' or 'bottom'.
*
* @return void
*/
protected function extra_tablenav( $which ): void {
$all_sources = $this->get_sources_list();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
$current_source = File::sanitize_source( wp_unslash( $_GET['source'] ?? '' ) );
?>
<div class="alignleft actions">
<?php if ( 'top' === $which ) : ?>
<label for="filter-by-source" class="screen-reader-text"><?php esc_html_e( 'Filter by log source', 'woocommerce' ); ?></label>
<select name="source" id="filter-by-source">
<option<?php selected( $current_source, '' ); ?> value=""><?php esc_html_e( 'All sources', 'woocommerce' ); ?></option>
<?php foreach ( $all_sources as $source ) : ?>
<option<?php selected( $current_source, $source ); ?> value="<?php echo esc_attr( $source ); ?>">
<?php echo esc_html( $source ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
submit_button(
__( 'Filter', 'woocommerce' ),
'',
'filter_action',
false,
array(
'id' => 'logs-filter-submit',
)
);
?>
<?php endif; ?>
</div>
<?php
}
/**
* Set up the column header info.
*
* @return void
*/
public function prepare_column_headers(): void {
$this->_column_headers = array(
$this->get_columns(),
get_hidden_columns( $this->screen ),
$this->get_sortable_columns(),
$this->get_primary_column(),
);
}
/**
* Prepares the list of items for displaying.
*
* @return void
*/
public function prepare_items(): void {
$per_page = $this->get_items_per_page(
self::PER_PAGE_USER_OPTION_KEY,
$this->get_per_page_default()
);
$defaults = array(
'per_page' => $per_page,
'offset' => ( $this->get_pagenum() - 1 ) * $per_page,
);
$file_args = wp_parse_args(
$this->page_controller->get_query_params( array( 'order', 'orderby', 'source' ) ),
$defaults
);
$total_items = $this->file_controller->get_files( $file_args, true );
if ( is_wp_error( $total_items ) ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html( $total_items->get_error_message() )
);
return;
}
$total_pages = ceil( $total_items / $per_page );
$items = $this->file_controller->get_files( $file_args );
$this->items = $items;
$this->set_pagination_args(
array(
'per_page' => $per_page,
'total_items' => $total_items,
'total_pages' => $total_pages,
)
);
}
/**
* Gets a list of columns.
*
* @return array
*/
public function get_columns(): array {
$columns = array(
'cb' => '<input type="checkbox" />',
'source' => esc_html__( 'Source', 'woocommerce' ),
'created' => esc_html__( 'Date created', 'woocommerce' ),
'modified' => esc_html__( 'Date modified', 'woocommerce' ),
'size' => esc_html__( 'File size', 'woocommerce' ),
);
return $columns;
}
/**
* Gets a list of sortable columns.
*
* @return array
*/
protected function get_sortable_columns(): array {
$sortable = array(
'source' => array( 'source' ),
'created' => array( 'created' ),
'modified' => array( 'modified', true ),
'size' => array( 'size' ),
);
return $sortable;
}
/**
* Render the checkbox column.
*
* @param File $item The current log file being rendered.
*
* @return string
*/
public function column_cb( $item ): string {
ob_start();
?>
<input
id="cb-select-<?php echo esc_attr( $item->get_file_id() ); ?>"
type="checkbox"
name="file_id[]"
value="<?php echo esc_attr( $item->get_file_id() ); ?>"
/>
<label for="cb-select-<?php echo esc_attr( $item->get_file_id() ); ?>">
<span class="screen-reader-text">
<?php
printf(
// translators: 1. a date, 2. a slug-style name for a file.
esc_html__( 'Select the %1$s log file for %2$s', 'woocommerce' ),
esc_html( gmdate( get_option( 'date_format' ), $item->get_created_timestamp() ) ),
esc_html( $item->get_source() )
);
?>
</span>
</label>
<?php
return ob_get_clean();
}
/**
* Render the source column.
*
* @param File $item The current log file being rendered.
*
* @return string
*/
public function column_source( $item ): string {
$log_file = $item->get_file_id();
$single_file_url = add_query_arg(
array(
'view' => 'single_file',
'file_id' => $log_file,
),
$this->page_controller->get_logs_tab_url()
);
$rotation = '';
if ( ! is_null( $item->get_rotation() ) ) {
$rotation = sprintf(
' &ndash; <span class="post-state">%d</span>',
$item->get_rotation()
);
}
return sprintf(
'<a class="row-title" href="%1$s">%2$s</a>%3$s',
esc_url( $single_file_url ),
esc_html( $item->get_source() ),
$rotation
);
}
/**
* Render the created column.
*
* @param File $item The current log file being rendered.
*
* @return string
*/
public function column_created( $item ): string {
$timestamp = $item->get_created_timestamp();
return gmdate( 'Y-m-d', $timestamp );
}
/**
* Render the modified column.
*
* @param File $item The current log file being rendered.
*
* @return string
*/
public function column_modified( $item ): string {
$timestamp = $item->get_modified_timestamp();
return gmdate( 'Y-m-d H:i:s', $timestamp );
}
/**
* Render the size column.
*
* @param File $item The current log file being rendered.
*
* @return string
*/
public function column_size( $item ): string {
$size = $item->get_file_size();
return size_format( $size );
}
/**
* Helper to get the default value for the per_page arg.
*
* @return int
*/
public function get_per_page_default(): int {
return $this->file_controller::DEFAULTS_GET_FILES['per_page'];
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
use WP_List_Table;
/**
* SearchListTable class.
*/
class SearchListTable extends WP_List_Table {
/**
* The user option key for saving the preferred number of search results displayed per page.
*
* @const string
*/
public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_search_results_per_page';
/**
* Instance of FileController.
*
* @var FileController
*/
private $file_controller;
/**
* Instance of PageController.
*
* @var PageController
*/
private $page_controller;
/**
* SearchListTable class.
*
* @param FileController $file_controller Instance of FileController.
* @param PageController $page_controller Instance of PageController.
*/
public function __construct( FileController $file_controller, PageController $page_controller ) {
$this->file_controller = $file_controller;
$this->page_controller = $page_controller;
parent::__construct(
array(
'singular' => 'wc-logs-search-result',
'plural' => 'wc-logs-search-results',
'ajax' => false,
)
);
}
/**
* Render message when there are no items.
*
* @return void
*/
public function no_items(): void {
esc_html_e( 'No search results.', 'woocommerce' );
}
/**
* Set up the column header info.
*
* @return void
*/
public function prepare_column_headers(): void {
$this->_column_headers = array(
$this->get_columns(),
array(),
array(),
$this->get_primary_column(),
);
}
/**
* Prepares the list of items for displaying.
*
* @return void
*/
public function prepare_items(): void {
$per_page = $this->get_items_per_page(
self::PER_PAGE_USER_OPTION_KEY,
$this->get_per_page_default()
);
$args = array(
'per_page' => $per_page,
'offset' => ( $this->get_pagenum() - 1 ) * $per_page,
);
$file_args = $this->page_controller->get_query_params(
array( 'date_end', 'date_filter', 'date_start', 'order', 'orderby', 'search', 'source' )
);
$search = $file_args['search'];
unset( $file_args['search'] );
$total_items = $this->file_controller->search_within_files( $search, $args, $file_args, true );
if ( is_wp_error( $total_items ) ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html( $total_items->get_error_message() )
);
return;
}
if ( $total_items >= $this->file_controller::SEARCH_MAX_RESULTS ) {
printf(
'<div class="notice notice-info"><p>%s</p></div>',
sprintf(
// translators: %s is a number.
esc_html__( 'The number of search results has reached the limit of %s. Try refining your search.', 'woocommerce' ),
esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_RESULTS ) )
)
);
}
$total_pages = ceil( $total_items / $per_page );
$results = $this->file_controller->search_within_files( $search, $args, $file_args );
$this->items = $results;
$this->set_pagination_args(
array(
'per_page' => $per_page,
'total_items' => $total_items,
'total_pages' => $total_pages,
)
);
}
/**
* Gets a list of columns.
*
* @return array
*/
public function get_columns(): array {
$columns = array(
'file_id' => esc_html__( 'File', 'woocommerce' ),
'line_number' => esc_html__( 'Line #', 'woocommerce' ),
'line' => esc_html__( 'Matched Line', 'woocommerce' ),
);
return $columns;
}
/**
* Render the file_id column.
*
* @param array $item The current search result being rendered.
*
* @return string
*/
public function column_file_id( array $item ): string {
// Add a word break after the rotation number, if it exists.
$file_id = preg_replace( '/\.([0-9])+\-/', '.\1<wbr>-', $item['file_id'] );
return wp_kses( $file_id, array( 'wbr' => array() ) );
}
/**
* Render the line_number column.
*
* @param array $item The current search result being rendered.
*
* @return string
*/
public function column_line_number( array $item ): string {
$match_url = add_query_arg(
array(
'view' => 'single_file',
'file_id' => $item['file_id'],
),
$this->page_controller->get_logs_tab_url() . '#L' . absint( $item['line_number'] )
);
return sprintf(
'<a href="%1$s">%2$s</a>',
esc_url( $match_url ),
sprintf(
// translators: %s is a line number in a file.
esc_html__( 'Line %s', 'woocommerce' ),
number_format_i18n( absint( $item['line_number'] ) )
)
);
}
/**
* Render the line column.
*
* @param array $item The current search result being rendered.
*
* @return string
*/
public function column_line( array $item ): string {
$params = $this->page_controller->get_query_params( array( 'search' ) );
$line = $item['line'];
// Highlight matches within the line.
$pattern = preg_quote( $params['search'], '/' );
preg_match_all( "/$pattern/i", $line, $matches, PREG_OFFSET_CAPTURE );
if ( is_array( $matches[0] ) && count( $matches[0] ) >= 1 ) {
$length_change = 0;
foreach ( $matches[0] as $match ) {
$replace = '<span class="search-match">' . $match[0] . '</span>';
$offset = $match[1] + $length_change;
$orig_length = strlen( $match[0] );
$replace_length = strlen( $replace );
$line = substr_replace( $line, $replace, $offset, $orig_length );
$length_change += $replace_length - $orig_length;
}
}
return wp_kses_post( $line );
}
/**
* Helper to get the default value for the per_page arg.
*
* @return int
*/
public function get_per_page_default(): int {
return $this->file_controller::DEFAULTS_SEARCH_WITHIN_FILES['per_page'];
}
}

View File

@@ -0,0 +1,303 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController };
use WC_Log_Handler;
/**
* LogHandlerFileV2 class.
*/
class LogHandlerFileV2 extends WC_Log_Handler {
/**
* Instance of the FileController class.
*
* @var FileController
*/
private $file_controller;
/**
* Instance of the Settings class.
*
* @var Settings
*/
private $settings;
/**
* LogHandlerFileV2 class.
*/
public function __construct() {
$this->file_controller = wc_get_container()->get( FileController::class );
$this->settings = wc_get_container()->get( Settings::class );
}
/**
* Handle a log entry.
*
* @param int $timestamp Log timestamp.
* @param string $level emergency|alert|critical|error|warning|notice|info|debug.
* @param string $message Log message.
* @param array $context {
* Optional. Additional information for log handlers. Any data can be added here, but there are some array
* keys that have special behavior.
*
* @type string $source Determines which log file to write to. Must be at least 3 characters in length.
* @type bool $backtrace True to include a backtrace that shows where the logging function got called.
* }
*
* @return bool False if value was not handled and true if value was handled.
*/
public function handle( $timestamp, $level, $message, $context ) {
$context = (array) $context;
if ( isset( $context['source'] ) && is_string( $context['source'] ) && strlen( $context['source'] ) >= 3 ) {
$source = sanitize_title( trim( $context['source'] ) );
} else {
$source = $this->determine_source();
}
$entry = static::format_entry( $timestamp, $level, $message, $context );
$written = $this->file_controller->write_to_file( $source, $entry, $timestamp );
if ( $written ) {
$this->file_controller->invalidate_cache();
}
return $written;
}
/**
* Builds a log entry text from level, timestamp, and message.
*
* @param int $timestamp Log timestamp.
* @param string $level emergency|alert|critical|error|warning|notice|info|debug.
* @param string $message Log message.
* @param array $context Additional information for log handlers.
*
* @return string Formatted log entry.
*/
protected static function format_entry( $timestamp, $level, $message, $context ) {
$time_string = static::format_time( $timestamp );
$level_string = strtoupper( $level );
if ( isset( $context['backtrace'] ) && true === filter_var( $context['backtrace'], FILTER_VALIDATE_BOOLEAN ) ) {
$context['backtrace'] = static::get_backtrace();
}
$context_for_entry = $context;
unset( $context_for_entry['source'] );
if ( ! empty( $context_for_entry ) ) {
$formatted_context = wp_json_encode( $context_for_entry, JSON_UNESCAPED_UNICODE );
$message .= stripslashes( " CONTEXT: $formatted_context" );
}
$entry = "$time_string $level_string $message";
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This filter is documented in includes/abstracts/abstract-wc-log-handler.php */
return apply_filters(
'woocommerce_format_log_entry',
$entry,
array(
'timestamp' => $timestamp,
'level' => $level,
'message' => $message,
'context' => $context,
)
);
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
}
/**
* Figures out a source string to use for a log entry based on where the log method was called from.
*
* @return string
*/
protected function determine_source(): string {
$source_roots = array(
'mu-plugin' => trailingslashit( Constants::get_constant( 'WPMU_PLUGIN_DIR' ) ),
'plugin' => trailingslashit( Constants::get_constant( 'WP_PLUGIN_DIR' ) ),
'theme' => trailingslashit( get_theme_root() ),
);
$source = '';
$backtrace = static::get_backtrace();
foreach ( $backtrace as $frame ) {
if ( ! isset( $frame['file'] ) ) {
continue;
}
foreach ( $source_roots as $type => $path ) {
if ( 0 === strpos( $frame['file'], $path ) ) {
$relative_path = trim( substr( $frame['file'], strlen( $path ) ), DIRECTORY_SEPARATOR );
if ( 'mu-plugin' === $type ) {
$info = pathinfo( $relative_path );
if ( '.' === $info['dirname'] ) {
$source = "$type-" . $info['filename'];
} else {
$source = "$type-" . $info['dirname'];
}
break 2;
}
$segments = explode( DIRECTORY_SEPARATOR, $relative_path );
if ( is_array( $segments ) ) {
$source = "$type-" . reset( $segments );
}
break 2;
}
}
}
if ( ! $source ) {
$source = 'log';
}
return sanitize_title( $source );
}
/**
* Delete all logs from a specific source.
*
* @param string $source The source of the log entries.
* @param bool $quiet Whether to suppress the deletion message.
*
* @return int The number of files that were deleted.
*/
public function clear( string $source, bool $quiet = false ): int {
$source = File::sanitize_source( $source );
$files = $this->file_controller->get_files(
array(
'source' => $source,
)
);
if ( is_wp_error( $files ) || count( $files ) < 1 ) {
return 0;
}
$file_ids = array_map(
fn( $file ) => $file->get_file_id(),
$files
);
$deleted = $this->file_controller->delete_files( $file_ids );
if ( $deleted > 0 && ! $quiet ) {
$this->handle(
time(),
'info',
sprintf(
esc_html(
// translators: %1$s is a number of log files, %2$s is a slug-style name for a file.
_n(
'%1$s log file from source %2$s was deleted.',
'%1$s log files from source %2$s were deleted.',
$deleted,
'woocommerce'
)
),
number_format_i18n( $deleted ),
sprintf(
'<code>%s</code>',
esc_html( $source )
)
),
array(
'source' => 'wc_logger',
'backtrace' => true,
)
);
}
return $deleted;
}
/**
* Delete all logs older than a specified timestamp.
*
* @param int $timestamp All files created before this timestamp will be deleted.
*
* @return int The number of files that were deleted.
*/
public function delete_logs_before_timestamp( int $timestamp = 0 ): int {
if ( ! $timestamp ) {
return 0;
}
$files = $this->file_controller->get_files(
array(
'date_filter' => 'created',
'date_start' => 1,
'date_end' => $timestamp,
)
);
if ( is_wp_error( $files ) ) {
return 0;
}
$files = array_filter(
$files,
function ( $file ) use ( $timestamp ) {
/**
* Allows preventing an expired log file from being deleted.
*
* @param bool $delete True to delete the file.
* @param File $file The log file object.
* @param int $timestamp The expiration threshold.
*
* @since 8.7.0
*/
$delete = apply_filters( 'woocommerce_logger_delete_expired_file', true, $file, $timestamp );
return boolval( $delete );
}
);
if ( count( $files ) < 1 ) {
return 0;
}
$file_ids = array_map(
fn( $file ) => $file->get_file_id(),
$files
);
$deleted = $this->file_controller->delete_files( $file_ids );
$retention_days = $this->settings->get_retention_period();
if ( $deleted > 0 ) {
$this->handle(
time(),
'info',
sprintf(
esc_html(
// translators: %s is a number of log files.
_n(
'%s expired log file was deleted.',
'%s expired log files were deleted.',
$deleted,
'woocommerce'
)
),
number_format_i18n( $deleted )
),
array(
'source' => 'wc_logger',
)
);
}
return $deleted;
}
}

View File

@@ -0,0 +1,822 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\{ LogHandlerFileV2, Settings };
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController, FileListTable, SearchListTable };
use WC_Admin_Status;
use WC_Log_Handler_File, WC_Log_Handler_DB;
use WC_Log_Levels;
use WP_List_Table;
/**
* PageController class.
*/
class PageController {
/**
* Instance of FileController.
*
* @var FileController
*/
private $file_controller;
/**
* Instance of Settings.
*
* @var Settings
*/
private $settings;
/**
* Instance of FileListTable or SearchListTable.
*
* @var FileListTable|SearchListTable
*/
private $list_table;
/**
* Initialize dependencies.
*
* @internal
*
* @param FileController $file_controller Instance of FileController.
* @param Settings $settings Instance of Settings.
*
* @return void
*/
final public function init(
FileController $file_controller,
Settings $settings
): void {
$this->file_controller = $file_controller;
$this->settings = $settings;
$this->init_hooks();
}
/**
* Add callbacks to hooks.
*
* @return void
*/
private function init_hooks(): void {
add_action( 'load-woocommerce_page_wc-status', array( $this, 'maybe_do_logs_tab_action' ), 2 );
add_action( 'wc_logs_load_tab', array( $this, 'setup_screen_options' ) );
add_action( 'wc_logs_load_tab', array( $this, 'handle_list_table_bulk_actions' ) );
add_action( 'wc_logs_load_tab', array( $this, 'notices' ) );
}
/**
* Determine if the current tab on the Status page is Logs, and if so, fire an action.
*
* @return void
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function maybe_do_logs_tab_action(): void {
$is_logs_tab = 'logs' === filter_input( INPUT_GET, 'tab' );
if ( $is_logs_tab ) {
$params = $this->get_query_params( array( 'view' ) );
/**
* Action fires when the Logs tab starts loading.
*
* @param string $view The current view within the Logs tab.
*
* @since 8.6.0
*/
do_action( 'wc_logs_load_tab', $params['view'] );
}
}
/**
* Notices to display on Logs screens.
*
* @return void
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function notices() {
if ( ! $this->settings->logging_is_enabled() ) {
add_action(
'admin_notices',
function () {
?>
<div class="notice notice-warning">
<p>
<?php
printf(
// translators: %s is a URL to another admin screen.
wp_kses_post( __( 'Logging is disabled. It can be enabled in <a href="%s">Logs Settings</a>.', 'woocommerce' ) ),
esc_url( add_query_arg( 'view', 'settings', $this->get_logs_tab_url() ) )
);
?>
</p>
</div>
<?php
}
);
}
}
/**
* Get the canonical URL for the Logs tab of the Status admin page.
*
* @return string
*/
public function get_logs_tab_url(): string {
return add_query_arg(
array(
'page' => 'wc-status',
'tab' => 'logs',
),
admin_url( 'admin.php' )
);
}
/**
* Render the "Logs" tab, depending on the current default log handler.
*
* @return void
*/
public function render(): void {
$handler = $this->settings->get_default_handler();
$params = $this->get_query_params( array( 'view' ) );
$this->render_section_nav();
if ( 'settings' === $params['view'] ) {
$this->settings->render_form();
return;
}
switch ( $handler ) {
case LogHandlerFileV2::class:
$this->render_filev2();
return;
case WC_Log_Handler_DB::class:
WC_Admin_Status::status_logs_db();
return;
case WC_Log_Handler_File::class:
WC_Admin_Status::status_logs_file();
return;
}
/**
* Action fires only if there is not a built-in rendering method for the current default log handler.
*
* This is intended as a way for extensions to render log views for custom handlers.
*
* @param string $handler
*
* @since 8.6.0
*/
do_action( 'wc_logs_render_page', $handler );
}
/**
* Render navigation to switch between logs browsing and settings.
*
* @return void
*/
private function render_section_nav(): void {
$params = $this->get_query_params( array( 'view' ) );
$browse_url = $this->get_logs_tab_url();
$settings_url = add_query_arg( 'view', 'settings', $this->get_logs_tab_url() );
?>
<ul class="subsubsub">
<li>
<?php
printf(
'<a href="%1$s"%2$s>%3$s</a>',
esc_url( $browse_url ),
'settings' !== $params['view'] ? ' class="current"' : '',
esc_html__( 'Browse', 'woocommerce' )
);
?>
|
</li>
<li>
<?php
printf(
'<a href="%1$s"%2$s>%3$s</a>',
esc_url( $settings_url ),
'settings' === $params['view'] ? ' class="current"' : '',
esc_html__( 'Settings', 'woocommerce' )
);
?>
</li>
</ul>
<br class="clear">
<?php
}
/**
* Render the views for the FileV2 log handler.
*
* @return void
*/
private function render_filev2(): void {
$params = $this->get_query_params( array( 'view' ) );
switch ( $params['view'] ) {
case 'list_files':
default:
$this->render_list_files_view();
break;
case 'search_results':
$this->render_search_results_view();
break;
case 'single_file':
$this->render_single_file_view();
break;
}
}
/**
* Render the file list view.
*
* @return void
*/
private function render_list_files_view(): void {
$params = $this->get_query_params( array( 'order', 'orderby', 'source', 'view' ) );
$defaults = $this->get_query_param_defaults();
$list_table = $this->get_list_table( $params['view'] );
$list_table->prepare_items();
?>
<header id="logs-header" class="wc-logs-header">
<h2>
<?php esc_html_e( 'Browse log files', 'woocommerce' ); ?>
</h2>
<?php $this->render_search_field(); ?>
</header>
<form id="logs-list-table-form" method="get">
<input type="hidden" name="page" value="wc-status" />
<input type="hidden" name="tab" value="logs" />
<?php foreach ( $params as $key => $value ) : ?>
<?php if ( $value !== $defaults[ $key ] ) : ?>
<input
type="hidden"
name="<?php echo esc_attr( $key ); ?>"
value="<?php echo esc_attr( $value ); ?>"
/>
<?php endif; ?>
<?php endforeach; ?>
<?php $list_table->display(); ?>
</form>
<?php
}
/**
* Render the single file view.
*
* @return void
*/
private function render_single_file_view(): void {
$params = $this->get_query_params( array( 'file_id', 'view' ) );
$file = $this->file_controller->get_file_by_id( $params['file_id'] );
if ( is_wp_error( $file ) ) {
?>
<div class="notice notice-error notice-inline">
<?php echo wp_kses_post( wpautop( $file->get_error_message() ) ); ?>
<?php
printf(
'<p><a href="%1$s">%2$s</a></p>',
esc_url( $this->get_logs_tab_url() ),
esc_html__( 'Return to the file list.', 'woocommerce' )
);
?>
</div>
<?php
return;
}
$rotations = $this->file_controller->get_file_rotations( $file->get_file_id() );
$rotation_url_base = add_query_arg( 'view', 'single_file', $this->get_logs_tab_url() );
$download_url = add_query_arg(
array(
'action' => 'export',
'file_id' => array( $file->get_file_id() ),
),
wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
);
$delete_url = add_query_arg(
array(
'action' => 'delete',
'file_id' => array( $file->get_file_id() ),
),
wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
);
$delete_confirmation_js = sprintf(
"return window.confirm( '%s' )",
esc_js( __( 'Delete this log file permanently?', 'woocommerce' ) )
);
$stream = $file->get_stream();
$line_number = 1;
?>
<header id="logs-header" class="wc-logs-header">
<h2>
<?php
printf(
// translators: %s is the name of a log file.
esc_html__( 'Viewing log file %s', 'woocommerce' ),
sprintf(
'<span class="file-id">%s</span>',
esc_html( $file->get_file_id() )
)
);
?>
</h2>
<?php if ( count( $rotations ) > 1 ) : ?>
<nav class="wc-logs-single-file-rotations">
<h3><?php esc_html_e( 'File rotations:', 'woocommerce' ); ?></h3>
<ul class="wc-logs-rotation-links">
<?php if ( isset( $rotations['current'] ) ) : ?>
<?php
printf(
'<li><a href="%1$s" class="button button-small button-%2$s">%3$s</a></li>',
esc_url( add_query_arg( 'file_id', $rotations['current']->get_file_id(), $rotation_url_base ) ),
$file->get_file_id() === $rotations['current']->get_file_id() ? 'primary' : 'secondary',
esc_html__( 'Current', 'woocommerce' )
);
unset( $rotations['current'] );
?>
<?php endif; ?>
<?php foreach ( $rotations as $rotation ) : ?>
<?php
printf(
'<li><a href="%1$s" class="button button-small button-%2$s">%3$s</a></li>',
esc_url( add_query_arg( 'file_id', $rotation->get_file_id(), $rotation_url_base ) ),
$file->get_file_id() === $rotation->get_file_id() ? 'primary' : 'secondary',
absint( $rotation->get_rotation() )
);
?>
<?php endforeach; ?>
</ul>
</nav>
<?php endif; ?>
<div class="wc-logs-single-file-actions">
<?php
// Download button.
printf(
'<a href="%1$s" class="button button-secondary">%2$s</a>',
esc_url( $download_url ),
esc_html__( 'Download', 'woocommerce' )
);
?>
<?php
// Delete button.
printf(
'<a href="%1$s" class="button button-secondary" onclick="%2$s">%3$s</a>',
esc_url( $delete_url ),
esc_attr( $delete_confirmation_js ),
esc_html__( 'Delete permanently', 'woocommerce' )
);
?>
</div>
</header>
<section id="logs-entries" class="wc-logs-entries">
<?php while ( ! feof( $stream ) ) : ?>
<?php
$line = fgets( $stream );
if ( is_string( $line ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_line does the escaping.
echo $this->format_line( $line, $line_number );
++$line_number;
}
?>
<?php endwhile; ?>
</section>
<script>
// Clear the line number hash and highlight with a click.
document.documentElement.addEventListener( 'click', ( event ) => {
if ( window.location.hash && ! event.target.classList.contains( 'line-anchor' ) ) {
let scrollPos = document.documentElement.scrollTop;
window.location.hash = '';
document.documentElement.scrollTop = scrollPos;
history.replaceState( null, '', window.location.pathname + window.location.search );
}
} );
</script>
<?php
}
/**
* Render the search results view.
*
* @return void
*/
private function render_search_results_view(): void {
$params = $this->get_query_params( array( 'view' ) );
$list_table = $this->get_list_table( $params['view'] );
$list_table->prepare_items();
?>
<header id="logs-header" class="wc-logs-header">
<h2><?php esc_html_e( 'Search results', 'woocommerce' ); ?></h2>
<?php $this->render_search_field(); ?>
</header>
<?php $list_table->display(); ?>
<?php
}
/**
* Get the default values for URL query params for FileV2 views.
*
* @return string[]
*/
public function get_query_param_defaults(): array {
return array(
'file_id' => '',
'order' => $this->file_controller::DEFAULTS_GET_FILES['order'],
'orderby' => $this->file_controller::DEFAULTS_GET_FILES['orderby'],
'search' => '',
'source' => $this->file_controller::DEFAULTS_GET_FILES['source'],
'view' => 'list_files',
);
}
/**
* Get and validate URL query params for FileV2 views.
*
* @param array $param_keys Optional. The names of the params you want to get.
*
* @return array
*/
public function get_query_params( array $param_keys = array() ): array {
$defaults = $this->get_query_param_defaults();
$params = filter_input_array(
INPUT_GET,
array(
'file_id' => array(
'filter' => FILTER_CALLBACK,
'options' => function ( $file_id ) {
return sanitize_file_name( wp_unslash( $file_id ) );
},
),
'order' => array(
'filter' => FILTER_VALIDATE_REGEXP,
'options' => array(
'regexp' => '/^(asc|desc)$/i',
'default' => $defaults['order'],
),
),
'orderby' => array(
'filter' => FILTER_VALIDATE_REGEXP,
'options' => array(
'regexp' => '/^(created|modified|source|size)$/',
'default' => $defaults['orderby'],
),
),
'search' => array(
'filter' => FILTER_CALLBACK,
'options' => function ( $search ) {
return esc_html( wp_unslash( $search ) );
},
),
'source' => array(
'filter' => FILTER_CALLBACK,
'options' => function ( $source ) {
return File::sanitize_source( wp_unslash( $source ) );
},
),
'view' => array(
'filter' => FILTER_VALIDATE_REGEXP,
'options' => array(
'regexp' => '/^(list_files|single_file|search_results|settings)$/',
'default' => $defaults['view'],
),
),
),
false
);
$params = wp_parse_args( $params, $defaults );
if ( count( $param_keys ) > 0 ) {
$params = array_intersect_key( $params, array_flip( $param_keys ) );
}
return $params;
}
/**
* Get and cache an instance of the list table.
*
* @param string $view The current view, which determines which list table class to get.
*
* @return FileListTable|SearchListTable
*/
private function get_list_table( string $view ) {
if ( $this->list_table instanceof WP_List_Table ) {
return $this->list_table;
}
switch ( $view ) {
case 'list_files':
$this->list_table = new FileListTable( $this->file_controller, $this );
break;
case 'search_results':
$this->list_table = new SearchListTable( $this->file_controller, $this );
break;
}
return $this->list_table;
}
/**
* Register screen options for the logging views.
*
* @param string $view The current view within the Logs tab.
*
* @return void
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function setup_screen_options( string $view ): void {
$handler = $this->settings->get_default_handler();
$list_table = null;
switch ( $handler ) {
case LogHandlerFileV2::class:
if ( in_array( $view, array( 'list_files', 'search_results' ), true ) ) {
$list_table = $this->get_list_table( $view );
}
break;
case 'WC_Log_Handler_DB':
$list_table = WC_Admin_Status::get_db_log_list_table();
break;
}
if ( $list_table instanceof WP_List_Table ) {
// Ensure list table columns are initialized early enough to enable column hiding, if available.
$list_table->prepare_column_headers();
add_screen_option(
'per_page',
array(
'default' => $list_table->get_per_page_default(),
'option' => $list_table::PER_PAGE_USER_OPTION_KEY,
)
);
}
}
/**
* Process bulk actions initiated from the log file list table.
*
* @param string $view The current view within the Logs tab.
*
* @return void
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function handle_list_table_bulk_actions( string $view ): void {
// Bail if we're not using the file handler.
if ( LogHandlerFileV2::class !== $this->settings->get_default_handler() ) {
return;
}
$params = $this->get_query_params( array( 'file_id' ) );
// Bail if this is not the list table view.
if ( 'list_files' !== $view ) {
return;
}
$action = $this->get_list_table( $view )->current_action();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : $this->get_logs_tab_url();
if ( $action ) {
check_admin_referer( 'bulk-log-files' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to manage log files.', 'woocommerce' ) );
}
$sendback = remove_query_arg( array( 'deleted' ), wp_get_referer() );
// Multiple file_id[] params will be filtered separately, but assigned to $files as an array.
$file_ids = $params['file_id'];
if ( ! is_array( $file_ids ) || count( $file_ids ) < 1 ) {
wp_safe_redirect( $sendback );
exit;
}
switch ( $action ) {
case 'export':
if ( 1 === count( $file_ids ) ) {
$export_error = $this->file_controller->export_single_file( reset( $file_ids ) );
} else {
$export_error = $this->file_controller->export_multiple_files( $file_ids );
}
if ( is_wp_error( $export_error ) ) {
wp_die( wp_kses_post( $export_error->get_error_message() ) );
}
break;
case 'delete':
$deleted = $this->file_controller->delete_files( $file_ids );
$sendback = add_query_arg( 'deleted', $deleted, $sendback );
/**
* If the delete action was triggered on the single file view, don't redirect back there
* since the file doesn't exist anymore.
*/
$sendback = remove_query_arg( array( 'view', 'file_id' ), $sendback );
break;
}
$sendback = remove_query_arg( array( 'action', 'action2' ), $sendback );
wp_safe_redirect( $sendback );
exit;
} elseif ( ! empty( $_REQUEST['_wp_http_referer'] ) ) {
$removable_args = array( '_wp_http_referer', '_wpnonce', 'action', 'action2', 'filter_action' );
wp_safe_redirect( remove_query_arg( $removable_args, $request_uri ) );
exit;
}
$deleted = filter_input( INPUT_GET, 'deleted', FILTER_VALIDATE_INT );
if ( is_numeric( $deleted ) ) {
add_action(
'admin_notices',
function () use ( $deleted ) {
?>
<div class="notice notice-info is-dismissible">
<p>
<?php
printf(
// translators: %s is a number of files.
esc_html( _n( '%s log file deleted.', '%s log files deleted.', $deleted, 'woocommerce' ) ),
esc_html( number_format_i18n( $deleted ) )
);
?>
</p>
</div>
<?php
}
);
}
}
/**
* Format a log file line.
*
* @param string $line The unformatted log file line.
* @param int $line_number The line number.
*
* @return string
*/
private function format_line( string $line, int $line_number ): string {
$classes = array( 'line' );
$line = esc_html( $line );
if ( empty( $line ) ) {
$line = '&nbsp;';
}
$segments = explode( ' ', $line, 3 );
$has_timestamp = false;
$has_level = false;
if ( isset( $segments[0] ) && false !== strtotime( $segments[0] ) ) {
$classes[] = 'log-entry';
$segments[0] = sprintf(
'<span class="log-timestamp">%s</span>',
$segments[0]
);
$has_timestamp = true;
}
if ( isset( $segments[1] ) && WC_Log_Levels::is_valid_level( strtolower( $segments[1] ) ) ) {
$segments[1] = sprintf(
'<span class="%1$s">%2$s</span>',
esc_attr( 'log-level log-level--' . strtolower( $segments[1] ) ),
esc_html( WC_Log_Levels::get_level_label( strtolower( $segments[1] ) ) )
);
$has_level = true;
}
if ( isset( $segments[2] ) && $has_timestamp && $has_level ) {
$message_chunks = explode( 'CONTEXT:', $segments[2], 2 );
if ( isset( $message_chunks[1] ) ) {
try {
$maybe_json = html_entity_decode( addslashes( trim( $message_chunks[1] ) ) );
// Decode for validation.
$context = json_decode( $maybe_json, false, 512, JSON_THROW_ON_ERROR );
// Re-encode to make it pretty.
$context = wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
$message_chunks[1] = sprintf(
'<details><summary>%1$s</summary>%2$s</details>',
esc_html__( 'Additional context', 'woocommerce' ),
stripslashes( $context )
);
$segments[2] = implode( ' ', $message_chunks );
$classes[] = 'has-context';
} catch ( \JsonException $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// It's not valid JSON so don't do anything with it.
}
}
}
if ( count( $segments ) > 1 ) {
$line = implode( ' ', $segments );
}
$classes = implode( ' ', $classes );
return sprintf(
'<span id="L%1$d" class="%2$s">%3$s%4$s</span>',
absint( $line_number ),
esc_attr( $classes ),
sprintf(
'<a href="#L%1$d" class="line-anchor"></a>',
absint( $line_number )
),
sprintf(
'<span class="line-content">%s</span>',
wp_kses_post( $line )
)
);
}
/**
* Render a form for searching within log files.
*
* @return void
*/
private function render_search_field(): void {
$params = $this->get_query_params( array( 'date_end', 'date_filter', 'date_start', 'search', 'source' ) );
$defaults = $this->get_query_param_defaults();
$file_count = $this->file_controller->get_files( $params, true );
if ( $file_count > 0 ) {
?>
<form id="logs-search" class="wc-logs-search" method="get">
<fieldset class="wc-logs-search-fieldset">
<input type="hidden" name="page" value="wc-status" />
<input type="hidden" name="tab" value="logs" />
<input type="hidden" name="view" value="search_results" />
<?php foreach ( $params as $key => $value ) : ?>
<?php if ( $value !== $defaults[ $key ] ) : ?>
<input
type="hidden"
name="<?php echo esc_attr( $key ); ?>"
value="<?php echo esc_attr( $value ); ?>"
/>
<?php endif; ?>
<?php endforeach; ?>
<label for="logs-search-field">
<?php esc_html_e( 'Search within these files', 'woocommerce' ); ?>
<input
id="logs-search-field"
class="wc-logs-search-field"
type="text"
name="search"
value="<?php echo esc_attr( $params['search'] ); ?>"
/>
</label>
<?php submit_button( __( 'Search', 'woocommerce' ), 'secondary', null, false ); ?>
</fieldset>
<?php if ( $file_count >= $this->file_controller::SEARCH_MAX_FILES ) : ?>
<div class="wc-logs-search-notice">
<?php
printf(
// translators: %s is a number.
esc_html__(
'⚠️ Only %s files can be searched at one time. Try filtering the file list before searching.',
'woocommerce'
),
esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_FILES ) )
);
?>
</div>
<?php endif; ?>
</form>
<?php
}
}
}

View File

@@ -0,0 +1,532 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\File;
use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WC_Admin_Settings;
use WC_Log_Handler_DB, WC_Log_Handler_File, WC_Log_Levels;
use WP_Filesystem_Direct;
/**
* Settings class.
*/
class Settings {
/**
* Default values for logging settings.
*
* @const array
*/
private const DEFAULTS = array(
'logging_enabled' => true,
'default_handler' => LogHandlerFileV2::class,
'retention_period_days' => 30,
'level_threshold' => 'none',
);
/**
* The prefix for settings keys used in the options table.
*
* @const string
*/
private const PREFIX = 'woocommerce_logs_';
/**
* Class Settings.
*/
public function __construct() {
add_action( 'wc_logs_load_tab', array( $this, 'save_settings' ) );
}
/**
* Get the directory for storing log files.
*
* The `wp_upload_dir` function takes into account the possibility of multisite, and handles changing
* the directory if the context is switched to a different site in the network mid-request.
*
* @param bool $create_dir Optional. True to attempt to create the log directory if it doesn't exist. Default true.
*
* @return string The full directory path, with trailing slash.
*/
public static function get_log_directory( bool $create_dir = true ): string {
if ( true === Constants::get_constant( 'WC_LOG_DIR_CUSTOM' ) ) {
$dir = Constants::get_constant( 'WC_LOG_DIR' );
} else {
$upload_dir = wc_get_container()->get( LegacyProxy::class )->call_function( 'wp_upload_dir', null, $create_dir );
/**
* Filter to change the directory for storing WooCommerce's log files.
*
* @param string $dir The full directory path, with trailing slash.
*
* @since 8.8.0
*/
$dir = apply_filters( 'woocommerce_log_directory', $upload_dir['basedir'] . '/wc-logs/' );
}
$dir = trailingslashit( $dir );
if ( true === $create_dir ) {
$realpath = realpath( $dir );
if ( false === $realpath ) {
$result = wp_mkdir_p( $dir );
if ( true === $result ) {
// Create infrastructure to prevent listing contents of the logs directory.
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
$filesystem->put_contents( $dir . '.htaccess', 'deny from all' );
$filesystem->put_contents( $dir . 'index.html', '' );
} catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Creation failed.
}
}
}
}
return $dir;
}
/**
* The definitions used by WC_Admin_Settings to render and save settings controls.
*
* @return array
*/
private function get_settings_definitions(): array {
$settings = array(
'start' => array(
'title' => __( 'Logs settings', 'woocommerce' ),
'id' => self::PREFIX . 'settings',
'type' => 'title',
),
'logging_enabled' => array(
'title' => __( 'Logger', 'woocommerce' ),
'desc' => __( 'Enable logging', 'woocommerce' ),
'id' => self::PREFIX . 'logging_enabled',
'type' => 'checkbox',
'value' => $this->logging_is_enabled() ? 'yes' : 'no',
'default' => self::DEFAULTS['logging_enabled'] ? 'yes' : 'no',
'autoload' => false,
),
'default_handler' => array(),
'retention_period_days' => array(),
'level_threshold' => array(),
'end' => array(
'id' => self::PREFIX . 'settings',
'type' => 'sectionend',
),
);
if ( true === $this->logging_is_enabled() ) {
$settings['default_handler'] = $this->get_default_handler_setting_definition();
$settings['retention_period_days'] = $this->get_retention_period_days_setting_definition();
$settings['level_threshold'] = $this->get_level_threshold_setting_definition();
$default_handler = $this->get_default_handler();
if ( in_array( $default_handler, array( LogHandlerFileV2::class, WC_Log_Handler_File::class ), true ) ) {
$settings += $this->get_filesystem_settings_definitions();
} elseif ( WC_Log_Handler_DB::class === $default_handler ) {
$settings += $this->get_database_settings_definitions();
}
}
return $settings;
}
/**
* The definition for the default_handler setting.
*
* @return array
*/
private function get_default_handler_setting_definition(): array {
$handler_options = array(
LogHandlerFileV2::class => __( 'File system (default)', 'woocommerce' ),
WC_Log_Handler_DB::class => __( 'Database (not recommended on live sites)', 'woocommerce' ),
);
/**
* Filter the list of logging handlers that can be set as the default handler.
*
* @param array $handler_options An associative array of class_name => description.
*
* @since 8.6.0
*/
$handler_options = apply_filters( 'woocommerce_logger_handler_options', $handler_options );
$current_value = $this->get_default_handler();
if ( ! array_key_exists( $current_value, $handler_options ) ) {
$handler_options[ $current_value ] = $current_value;
}
$desc = array();
$desc[] = __( 'Note that if this setting is changed, any log entries that have already been recorded will remain stored in their current location, but will not migrate.', 'woocommerce' );
$hardcoded = ! is_null( Constants::get_constant( 'WC_LOG_HANDLER' ) );
if ( $hardcoded ) {
$desc[] = sprintf(
// translators: %s is the name of a code variable.
__( 'This setting cannot be changed here because it is defined in the %s constant.', 'woocommerce' ),
'<code>WC_LOG_HANDLER</code>'
);
}
return array(
'title' => __( 'Log storage', 'woocommerce' ),
'desc_tip' => __( 'This determines where log entries are saved.', 'woocommerce' ),
'id' => self::PREFIX . 'default_handler',
'type' => 'radio',
'value' => $current_value,
'default' => self::DEFAULTS['default_handler'],
'autoload' => false,
'options' => $handler_options,
'disabled' => $hardcoded ? array_keys( $handler_options ) : array(),
'desc' => implode( '<br><br>', $desc ),
'desc_at_end' => true,
);
}
/**
* The definition for the retention_period_days setting.
*
* @return array
*/
private function get_retention_period_days_setting_definition(): array {
$custom_attributes = array(
'min' => 1,
'step' => 1,
);
$desc = array();
$hardcoded = has_filter( 'woocommerce_logger_days_to_retain_logs' );
if ( $hardcoded ) {
$custom_attributes['disabled'] = 'true';
$desc[] = sprintf(
// translators: %s is the name of a filter hook.
__( 'This setting cannot be changed here because it is being set by a filter on the %s hook.', 'woocommerce' ),
'<code>woocommerce_logger_days_to_retain_logs</code>'
);
}
$file_delete_has_filter = LogHandlerFileV2::class === $this->get_default_handler() && has_filter( 'woocommerce_logger_delete_expired_file' );
if ( $file_delete_has_filter ) {
$desc[] = sprintf(
// translators: %s is the name of a filter hook.
__( 'The %s hook has a filter set, so some log files may have different retention settings.', 'woocommerce' ),
'<code>woocommerce_logger_delete_expired_file</code>'
);
}
return array(
'title' => __( 'Retention period', 'woocommerce' ),
'desc_tip' => __( 'This sets how many days log entries will be kept before being auto-deleted.', 'woocommerce' ),
'id' => self::PREFIX . 'retention_period_days',
'type' => 'number',
'value' => $this->get_retention_period(),
'default' => self::DEFAULTS['retention_period_days'],
'autoload' => false,
'custom_attributes' => $custom_attributes,
'css' => 'width:70px;',
'row_class' => 'logs-retention-period-days',
'suffix' => sprintf(
' %s',
__( 'days', 'woocommerce' ),
),
'desc' => implode( '<br><br>', $desc ),
);
}
/**
* The definition for the level_threshold setting.
*
* @return array
*/
private function get_level_threshold_setting_definition(): array {
$hardcoded = ! is_null( Constants::get_constant( 'WC_LOG_THRESHOLD' ) );
$desc = '';
if ( $hardcoded ) {
$desc = sprintf(
// translators: %1$s is the name of a code variable. %2$s is the name of a file.
__( 'This setting cannot be changed here because it is defined in the %1$s constant, probably in your %2$s file.', 'woocommerce' ),
'<code>WC_LOG_THRESHOLD</code>',
'<b>wp-config.php</b>'
);
}
$labels = WC_Log_Levels::get_all_level_labels();
$labels['none'] = __( 'None', 'woocommerce' );
$custom_attributes = array();
if ( $hardcoded ) {
$custom_attributes['disabled'] = 'true';
}
return array(
'title' => __( 'Level threshold', 'woocommerce' ),
'desc_tip' => __( 'This sets the minimum severity level of logs that will be stored. Lower severity levels will be ignored. "None" means all logs will be stored.', 'woocommerce' ),
'id' => self::PREFIX . 'level_threshold',
'type' => 'select',
'value' => $this->get_level_threshold(),
'default' => self::DEFAULTS['level_threshold'],
'autoload' => false,
'options' => $labels,
'custom_attributes' => $custom_attributes,
'css' => 'width:auto;',
'desc' => $desc,
);
}
/**
* The definitions used by WC_Admin_Settings to render settings related to filesystem log handlers.
*
* @return array
*/
private function get_filesystem_settings_definitions(): array {
$location_info = array();
$directory = self::get_log_directory();
$status_info = array();
try {
$filesystem = FilesystemUtil::get_wp_filesystem();
if ( $filesystem instanceof WP_Filesystem_Direct ) {
$status_info[] = __( '✅ Ready', 'woocommerce' );
} else {
$status_info[] = __( '⚠️ The file system is not configured for direct writes. This could cause problems for the logger.', 'woocommerce' );
$status_info[] = __( 'You may want to switch to the database for log storage.', 'woocommerce' );
}
} catch ( Exception $exception ) {
$status_info[] = __( '⚠️ The file system connection could not be initialized.', 'woocommerce' );
$status_info[] = __( 'You may want to switch to the database for log storage.', 'woocommerce' );
}
$location_info[] = sprintf(
// translators: %s is a location in the filesystem.
__( 'Log files are stored in this directory: %s', 'woocommerce' ),
sprintf(
'<code>%s</code>',
esc_html( $directory )
)
);
if ( ! wp_is_writable( $directory ) ) {
$location_info[] = __( '⚠️ This directory does not appear to be writable.', 'woocommerce' );
}
$location_info[] = sprintf(
// translators: %s is an amount of computer disk space, e.g. 5 KB.
__( 'Directory size: %s', 'woocommerce' ),
size_format( wc_get_container()->get( FileController::class )->get_log_directory_size() )
);
return array(
'file_start' => array(
'title' => __( 'File system settings', 'woocommerce' ),
'id' => self::PREFIX . 'settings',
'type' => 'title',
),
'file_status' => array(
'title' => __( 'Status', 'woocommerce' ),
'type' => 'info',
'text' => implode( "\n\n", $status_info ),
),
'log_directory' => array(
'title' => __( 'Location', 'woocommerce' ),
'type' => 'info',
'text' => implode( "\n\n", $location_info ),
),
'entry_format' => array(),
'file_end' => array(
'id' => self::PREFIX . 'settings',
'type' => 'sectionend',
),
);
}
/**
* The definitions used by WC_Admin_Settings to render settings related to database log handlers.
*
* @return array
*/
private function get_database_settings_definitions(): array {
global $wpdb;
$table = "{$wpdb->prefix}woocommerce_log";
$location_info = sprintf(
// translators: %s is the name of a table in the database.
__( 'Log entries are stored in this database table: %s', 'woocommerce' ),
"<code>$table</code>"
);
return array(
'file_start' => array(
'title' => __( 'Database settings', 'woocommerce' ),
'id' => self::PREFIX . 'settings',
'type' => 'title',
),
'database_table' => array(
'title' => __( 'Location', 'woocommerce' ),
'type' => 'info',
'text' => $location_info,
),
'file_end' => array(
'id' => self::PREFIX . 'settings',
'type' => 'sectionend',
),
);
}
/**
* Handle the submission of the settings form and update the settings values.
*
* @param string $view The current view within the Logs tab.
*
* @return void
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function save_settings( string $view ): void {
$is_saving = 'settings' === $view && isset( $_POST['save_settings'] );
if ( $is_saving ) {
check_admin_referer( self::PREFIX . 'settings' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to manage logging settings.', 'woocommerce' ) );
}
$settings = $this->get_settings_definitions();
WC_Admin_Settings::save_fields( $settings );
}
}
/**
* Render the settings page.
*
* @return void
*/
public function render_form(): void {
$settings = $this->get_settings_definitions();
?>
<form id="mainform" class="wc-logs-settings" method="post">
<?php WC_Admin_Settings::output_fields( $settings ); ?>
<?php
/**
* Action fires after the built-in logging settings controls have been rendered.
*
* This is intended as a way to allow other logging settings controls to be added by extensions.
*
* @param bool $enabled True if logging is currently enabled.
*
* @since 8.6.0
*/
do_action( 'wc_logs_settings_form_fields', $this->logging_is_enabled() );
?>
<?php wp_nonce_field( self::PREFIX . 'settings' ); ?>
<?php submit_button( __( 'Save changes', 'woocommerce' ), 'primary', 'save_settings' ); ?>
</form>
<?php
}
/**
* Determine the current value of the logging_enabled setting.
*
* @return bool
*/
public function logging_is_enabled(): bool {
$key = self::PREFIX . 'logging_enabled';
$enabled = WC_Admin_Settings::get_option( $key, self::DEFAULTS['logging_enabled'] );
$enabled = filter_var( $enabled, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
if ( is_null( $enabled ) ) {
$enabled = self::DEFAULTS['logging_enabled'];
}
return $enabled;
}
/**
* Determine the current value of the default_handler setting.
*
* @return string
*/
public function get_default_handler(): string {
$key = self::PREFIX . 'default_handler';
$handler = Constants::get_constant( 'WC_LOG_HANDLER' );
if ( is_null( $handler ) ) {
$handler = WC_Admin_Settings::get_option( $key );
}
if ( ! class_exists( $handler ) || ! is_a( $handler, 'WC_Log_Handler_Interface', true ) ) {
$handler = self::DEFAULTS['default_handler'];
}
return $handler;
}
/**
* Determine the current value of the retention_period_days setting.
*
* @return int
*/
public function get_retention_period(): int {
$key = self::PREFIX . 'retention_period_days';
$retention_period = self::DEFAULTS['retention_period_days'];
if ( has_filter( 'woocommerce_logger_days_to_retain_logs' ) ) {
/**
* Filter the retention period of log entries.
*
* @param int $days The number of days to retain log entries.
*
* @since 3.4.0
*/
$retention_period = apply_filters( 'woocommerce_logger_days_to_retain_logs', $retention_period );
} else {
$retention_period = WC_Admin_Settings::get_option( $key );
}
$retention_period = absint( $retention_period );
if ( $retention_period < 1 ) {
$retention_period = self::DEFAULTS['retention_period_days'];
}
return $retention_period;
}
/**
* Determine the current value of the level_threshold setting.
*
* @return string
*/
public function get_level_threshold(): string {
$key = self::PREFIX . 'level_threshold';
$threshold = Constants::get_constant( 'WC_LOG_THRESHOLD' );
if ( is_null( $threshold ) ) {
$threshold = WC_Admin_Settings::get_option( $key );
}
if ( ! WC_Log_Levels::is_valid_level( $threshold ) ) {
$threshold = self::DEFAULTS['level_threshold'];
}
return $threshold;
}
}

View File

@@ -0,0 +1,239 @@
<?php
/**
* WooCommerce Marketing.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Marketing feature.
*/
class Marketing {
use CouponsMovedTrait;
/**
* Constant representing the key for the submenu name value in the global $submenu array.
*
* @var int
*/
const SUBMENU_NAME_KEY = 0;
/**
* Constant representing the key for the submenu location value in the global $submenu array.
*
* @var int
*/
const SUBMENU_LOCATION_KEY = 2;
/**
* Class instance.
*
* @var Marketing instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_action( 'admin_menu', array( $this, 'register_pages' ), 5 );
add_action( 'admin_menu', array( $this, 'add_parent_menu_item' ), 6 );
// Overwrite submenu default ordering for marketing menu. High priority gives plugins the chance to register their own menu items.
add_action( 'admin_menu', array( $this, 'reorder_marketing_submenu' ), 99 );
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 30 );
}
/**
* Add main marketing menu item.
*
* Uses priority of 9 so other items can easily be added at the default priority (10).
*/
public function add_parent_menu_item() {
if ( ! Features::is_enabled( 'navigation' ) ) {
add_menu_page(
__( 'Marketing', 'woocommerce' ),
__( 'Marketing', 'woocommerce' ),
'manage_woocommerce',
'woocommerce-marketing',
null,
'dashicons-megaphone',
58
);
}
PageController::get_instance()->connect_page(
array(
'id' => 'woocommerce-marketing',
'title' => 'Marketing',
'capability' => 'manage_woocommerce',
'path' => 'wc-admin&path=/marketing',
)
);
}
/**
* Registers report pages.
*/
public function register_pages() {
$this->register_overview_page();
$controller = PageController::get_instance();
$defaults = array(
'parent' => 'woocommerce-marketing',
'existing_page' => false,
);
/**
* Filters marketing menu items.
*
* @since 4.1.0
* @param array $items Marketing pages.
*/
$marketing_pages = apply_filters( 'woocommerce_marketing_menu_items', array() );
foreach ( $marketing_pages as $marketing_page ) {
if ( ! is_array( $marketing_page ) ) {
continue;
}
$marketing_page = array_merge( $defaults, $marketing_page );
if ( $marketing_page['existing_page'] ) {
$controller->connect_page( $marketing_page );
} else {
$controller->register_page( $marketing_page );
}
}
}
/**
* Register the main Marketing page, which is Marketing > Overview.
*
* This is done separately because we need to ensure the page is registered properly and
* that the link is done properly. For some reason the normal page registration process
* gives us the wrong menu link.
*/
protected function register_overview_page() {
global $submenu;
// First register the page.
PageController::get_instance()->register_page(
array(
'id' => 'woocommerce-marketing-overview',
'title' => __( 'Overview', 'woocommerce' ),
'path' => 'wc-admin&path=/marketing',
'parent' => 'woocommerce-marketing',
)
);
// Now fix the path, since register_page() gets it wrong.
if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
return;
}
foreach ( $submenu['woocommerce-marketing'] as &$item ) {
// The "slug" (aka the path) is the third item in the array.
if ( 0 === strpos( $item[2], 'wc-admin' ) ) {
$item[2] = 'admin.php?page=' . $item[2];
}
}
}
/**
* Order marketing menu items alphabetically.
* Overview should be first, and Coupons should be second, followed by other marketing menu items.
*
* @return void
*/
public function reorder_marketing_submenu() {
global $submenu;
if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
return;
}
$marketing_submenu = $submenu['woocommerce-marketing'];
$new_menu_order = array();
// Overview should be first.
$overview_key = array_search( 'Overview', array_column( $marketing_submenu, self::SUBMENU_NAME_KEY ), true );
if ( false === $overview_key ) {
/*
* If Overview is not found, we may be on a site with a different language.
* We can use a fallback and try to find the overview page by its path.
*/
$overview_key = array_search( 'admin.php?page=wc-admin&path=/marketing', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true );
}
if ( false !== $overview_key ) {
$new_menu_order[] = $marketing_submenu[ $overview_key ];
array_splice( $marketing_submenu, $overview_key, 1 );
}
// Coupons should be second.
$coupons_key = array_search( 'Coupons', array_column( $marketing_submenu, self::SUBMENU_NAME_KEY ), true );
if ( false === $coupons_key ) {
/*
* If Coupons is not found, we may be on a site with a different language.
* We can use a fallback and try to find the coupons page by its path.
*/
$coupons_key = array_search( 'edit.php?post_type=shop_coupon', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true );
}
if ( false !== $coupons_key ) {
$new_menu_order[] = $marketing_submenu[ $coupons_key ];
array_splice( $marketing_submenu, $coupons_key, 1 );
}
// Sort the rest of the items alphabetically.
usort(
$marketing_submenu,
function ( $a, $b ) {
return strcmp( $a[0], $b[0] );
}
);
$new_menu_order = array_merge( $new_menu_order, $marketing_submenu );
$submenu['woocommerce-marketing'] = $new_menu_order; //phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
/**
* Add settings for marketing feature.
*
* @param array $settings Component settings.
* @return array
*/
public function component_settings( $settings ) {
// Bail early if not on a wc-admin powered page.
if ( ! PageController::is_admin_page() ) {
return $settings;
}
$settings['marketing']['installedExtensions'] = InstalledExtensions::get_data();
return $settings;
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* Marketing Specs Handler
*
* Fetches the specifications for the marketing feature from WooCommerce.com API.
*/
namespace Automattic\WooCommerce\Internal\Admin\Marketing;
/**
* Marketing Specifications Class.
*
* @internal
* @since x.x.x
*/
class MarketingSpecs {
/**
* Name of knowledge base post transient.
*
* @var string
*/
const KNOWLEDGE_BASE_TRANSIENT = 'wc_marketing_knowledge_base';
/**
* Load knowledge base posts from WooCommerce.com
*
* @param string|null $topic The topic of marketing knowledgebase to retrieve.
* @return array
*/
public function get_knowledge_base_posts( ?string $topic ): array {
// Default to the marketing topic (if no topic is set on the kb component).
if ( empty( $topic ) ) {
$topic = 'marketing';
}
$kb_transient = self::KNOWLEDGE_BASE_TRANSIENT . '_' . strtolower( $topic );
$posts = get_transient( $kb_transient );
if ( false === $posts ) {
$request_url = add_query_arg(
array(
'page' => 1,
'per_page' => 8,
'_embed' => 1,
),
'https://woocommerce.com/wp-json/wccom/marketing-knowledgebase/v1/posts/' . $topic
);
$request = wp_remote_get(
$request_url,
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$posts = array();
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$raw_posts = json_decode( $request['body'], true );
foreach ( $raw_posts as $raw_post ) {
$post = array(
'title' => html_entity_decode( $raw_post['title']['rendered'] ),
'date' => $raw_post['date_gmt'],
'link' => $raw_post['link'],
'author_name' => isset( $raw_post['author_name'] ) ? html_entity_decode( $raw_post['author_name'] ) : '',
'author_avatar' => isset( $raw_post['author_avatar_url'] ) ? $raw_post['author_avatar_url'] : '',
);
$featured_media = isset( $raw_post['_embedded']['wp:featuredmedia'] ) && is_array( $raw_post['_embedded']['wp:featuredmedia'] ) ? $raw_post['_embedded']['wp:featuredmedia'] : array();
if ( count( $featured_media ) > 0 ) {
$image = current( $featured_media );
$post['image'] = add_query_arg(
array(
'resize' => '650,340',
'crop' => 1,
),
$image['source_url']
);
}
$posts[] = $post;
}
}
set_transient(
$kb_transient,
$posts,
// Expire transient in 15 minutes if remote get failed.
empty( $posts ) ? 900 : DAY_IN_SECONDS
);
}
return $posts;
}
}

View File

@@ -0,0 +1,142 @@
<?php
/**
* WooCommerce Marketplace.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use WC_Helper_Options;
use WC_Helper_Updater;
/**
* Contains backend logic for the Marketplace feature.
*/
class Marketplace {
const MARKETPLACE_TAB_SLUG = 'woo';
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
add_action( 'init', array( $this, 'on_init' ) );
}
/**
* Hook into WordPress on init.
*/
public function on_init() {
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
// Add a Woo Marketplace link to the plugin install action links.
add_filter( 'install_plugins_tabs', array( $this, 'add_woo_plugin_install_action_link' ) );
add_action( 'install_plugins_pre_woo', array( $this, 'maybe_open_woo_tab' ) );
}
/**
* Registers report pages.
*/
public function register_pages() {
if ( ! function_exists( 'wc_admin_register_page' ) ) {
return;
}
$marketplace_pages = $this->get_marketplace_pages();
foreach ( $marketplace_pages as $marketplace_page ) {
if ( ! is_null( $marketplace_page ) ) {
wc_admin_register_page( $marketplace_page );
}
}
}
/**
* Get report pages.
*/
public function get_marketplace_pages() {
$marketplace_pages = array(
array(
'id' => 'woocommerce-marketplace',
'parent' => 'woocommerce',
'title' => __( 'Extensions', 'woocommerce' ) . $this->badge(),
'page_title' => __( 'Extensions', 'woocommerce' ),
'path' => '/extensions',
),
);
/**
* The marketplace items used in the menu.
*
* @since 8.0
*/
return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages );
}
private function badge(): string {
$option = WC_Helper_Options::get( 'my_subscriptions_tab_loaded' );
if ( ! $option ) {
return WC_Helper_Updater::get_updates_count_html();
}
return '';
}
/**
* Enqueue update script.
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_scripts( $hook_suffix ) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( 'woocommerce_page_wc-admin' !== $hook_suffix ) {
return;
}
if ( ! isset( $_GET['path'] ) || '/extensions' !== $_GET['path'] ) {
return;
}
// Enqueue WordPress updates script to enable plugin and theme installs and updates.
wp_enqueue_script( 'updates' );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Add a Woo Marketplace link to the plugin install action links.
*
* @param array $tabs Plugins list tabs.
* @return array
*/
public function add_woo_plugin_install_action_link( $tabs ) {
$tabs[ self::MARKETPLACE_TAB_SLUG ] = 'WooCommerce Marketplace';
return $tabs;
}
/**
* Open the Woo tab when the user clicks on the Woo link in the plugin installer.
*/
public function maybe_open_woo_tab() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['tab'] ) || self::MARKETPLACE_TAB_SLUG !== $_GET['tab'] ) {
return;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$woo_url = add_query_arg(
array(
'page' => 'wc-admin',
'path' => '/extensions',
'tab' => 'extensions',
'ref' => 'plugins',
),
admin_url( 'admin.php' )
);
wc_admin_record_tracks_event( 'marketplace_plugin_install_woo_clicked' );
wp_safe_redirect( $woo_url );
exit;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Determine if the mobile app banner shows on Android devices
*/
class MobileAppBanner {
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
}
/**
* Adds fields so that we can store user preferences for the mobile app banner
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'android_app_banner_dismissed',
)
);
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* WooCommerce Admin: Customize your online store with WooCommerce blocks.
*
* Adds a note to customize the client online store with WooCommerce blocks.
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Customize_Store_With_Blocks.
*/
class CustomizeStoreWithBlocks {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-customize-store-with-blocks';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// We want to show the note after fourteen days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 14 * DAY_IN_SECONDS ) ) {
return;
}
// Don't show if there aren't products.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
if ( 0 === count( $products ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Customize your online store with WooCommerce blocks', 'woocommerce' ) );
$note->set_content( __( 'With our blocks, you can select and display products, categories, filters, and more virtually anywhere on your site — no need to use shortcodes or edit lines of code. Learn more about how to use each one of them.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'customize-store-with-blocks',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-to-customize-your-online-store-with-woocommerce-blocks/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* WooCommerce Admin: How to customize your product catalog note provider
*
* Adds a note with a link to the customizer a day after adding the first product
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Enums\ProductStatus;
/**
* Class CustomizingProductCatalog
*
* @package Automattic\WooCommerce\Admin\Notes
*/
class CustomizingProductCatalog {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-customizing-product-catalog';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'status' => array( ProductStatus::PUBLISH ),
'orderby' => 'post_date',
'order' => 'DESC',
)
);
$products = $query->get_products();
// we need at least 1 product.
if ( 0 === $products->total ) {
return;
}
$product = $products->products[0];
$created_timestamp = $product->get_date_created()->getTimestamp();
$is_a_day_old = ( time() - $created_timestamp ) >= DAY_IN_SECONDS;
// the product must be at least 1 day old.
if ( ! $is_a_day_old ) {
return;
}
// store must not been active more than 14 days.
if ( self::wc_admin_active_for( DAY_IN_SECONDS * 14 ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'How to customize your product catalog', 'woocommerce' ) );
$note->set_content( __( 'You want your product catalog and images to look great and align with your brand. This guide will give you all the tips you need to get your products looking great in your store.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'day-after-first-product',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/document/woocommerce-customizer/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* WooCommerce Admin: EU VAT Number Note.
*
* Adds a note for EU store to install the EU VAT Number extension.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* EU_VAT_Number
*/
class EUVATNumber {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-eu-vat-number';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( 'yes' !== get_option( 'wc_connect_taxes_enabled', 'no' ) ) {
return;
}
$country_code = WC()->countries->get_base_country();
$eu_countries = WC()->countries->get_european_union_countries();
if ( ! in_array( $country_code, $eu_countries, true ) ) {
return;
}
$content = __( "If your store is based in the EU, we recommend using the EU VAT Number extension in addition to automated taxes. It provides your checkout with a field to collect and validate a customer's EU VAT number, if they have one.", 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Collect and validate EU VAT numbers at checkout', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/products/eu-vat-number/?utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* WooCommerce Admin Edit products on the move note.
*
* Adds a note to download the mobile app.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Edit_Products_On_The_Move
*/
class EditProductsOnTheMove {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-edit-products-on-the-move';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if this store is at least a year old.
$year_in_seconds = 365 * DAY_IN_SECONDS;
if ( ! self::wc_admin_active_for( $year_in_seconds ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
return;
}
if ( PerformanceOnMobile::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Edit products on the move', 'woocommerce' ) );
$note->set_content( __( 'Edit and create new products from your mobile devices with the Woo app', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Adds a note when the email improvements feature is enabled for existing stores
* or when the feature is not enabled to try the new templates.
*
* @since 9.9.0
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements as EmailImprovementsFeature;
/**
* EmailImprovements
*/
class EmailImprovements {
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-email-improvements';
/**
* Get the note.
*
* @return Note|void
*/
public static function get_note() {
if ( EmailImprovementsFeature::is_email_improvements_enabled_for_existing_stores() ) {
return self::get_email_improvements_enabled_note();
}
if ( EmailImprovementsFeature::should_notify_merchant_about_email_improvements() ) {
return self::get_try_email_improvements_note();
}
}
/**
* Get the note for when the email improvements feature is enabled for existing stores.
*
* @return Note
*/
private static function get_email_improvements_enabled_note() {
$note = new Note();
$note->set_title( __( 'Your store emails have had an upgrade!', 'woocommerce' ) );
$note->set_content( __( 'Weve made some exciting improvements to your email templates, including modern, shopper-friendly designs and new customization options. And if youre using a block theme, you can automatically sync your theme styles! Head to your email settings to explore the new changes.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'customize-your-emails',
__( 'Customize your emails', 'woocommerce' ),
'?page=wc-settings&tab=email'
);
return $note;
}
/**
* Get the note for when the email improvements feature is disabled.
*
* @return Note
*/
private static function get_try_email_improvements_note() {
$note = new Note();
$note->set_title( __( 'Store emails have had an upgrade!', 'woocommerce' ) );
$note->set_content( __( 'Weve made some exciting improvements to our email templates, including modern, shopper-friendly designs and new customization options. And if youre using a block theme, you can automatically sync your theme styles! Head to your email settings to explore the new features.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'try-the-new-templates',
__( 'Try the new templates', 'woocommerce' ),
'?page=wc-settings&tab=email&try-new-templates'
);
return $note;
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* WooCommerce Admin: Do you need help with adding your first product?
*
* Adds a note to ask the client if they need help adding their first product.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Enums\ProductStatus;
/**
* First_Product.
*/
class FirstProduct {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-first-product';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after seven days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// Don't show if there are products.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( ProductStatus::PUBLISH ),
)
);
$products = $query->get_products();
$count = $products->total;
if ( 0 !== $count ) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you need help with adding your first product?', 'woocommerce' ) );
$note->set_content( __( 'This video tutorial will help you go through the process of adding your first product in WooCommerce.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'first-product-watch-tutorial',
__( 'Watch tutorial', 'woocommerce' ),
'https://www.youtube.com/watch?v=sFtXa00Jf_o&list=PLHdG8zvZd0E575Ia8Mu3w1h750YLXNfsC&index=24'
);
return $note;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/**
* WooCommerce Admin (Dashboard) Giving feedback notes provider
*
* Adds notes to the merchant's inbox about giving feedback.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Survey;
/**
* Giving_Feedback_Notes
*/
class GivingFeedbackNotes {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-store-notice-giving-feedback-2';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
// Otherwise, create our new note.
$note = new Note();
$note->set_title( __( 'You\'re invited to share your experience', 'woocommerce' ) );
$note->set_content( __( 'Now that youve chosen us as a partner, our goal is to make sure we\'re providing the right tools to meet your needs. We\'re looking forward to having your feedback on the store setup experience so we can improve it in the future.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'share-feedback',
__( 'Share feedback', 'woocommerce' ),
Survey::get_url( '/store-setup-survey' )
);
return $note;
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* WooCommerce Admin Add Install Jetpack and WooCommerce Shipping & Tax Plugin Note Provider.
*
* Adds a note to the merchant's inbox prompting them to install the Jetpack
* and WooCommerce Shipping & Tax plugins after it fails to install during
* WooCommerce setup.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Install_JP_And_WCS_Plugins
*/
class InstallJPAndWCSPlugins {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-install-jp-and-wcs-plugins';
/**
* Constructor.
*/
public function __construct() {
add_action( 'woocommerce_note_action_install-jp-and-wcs-plugins', array( $this, 'install_jp_and_wcs_plugins' ) );
add_action( 'activated_plugin', array( $this, 'action_note' ) );
add_action( 'woocommerce_plugins_install_api_error', array( $this, 'on_install_error' ) );
add_action( 'woocommerce_plugins_install_error', array( $this, 'on_install_error' ) );
add_action( 'woocommerce_plugins_activate_error', array( $this, 'on_install_error' ) );
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$content = __( 'We noticed that there was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again and enjoy all the advantages of having the plugins connected to your store! Sorry for the inconvenience. The "Jetpack" and "WooCommerce Shipping & Tax" plugins will be installed & activated for free.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Uh oh... There was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again.', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'install-jp-and-wcs-plugins',
__( 'Install plugins', 'woocommerce' ),
false,
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Action the Install Jetpack and WooCommerce Shipping & Tax note, if any exists,
* and as long as both the Jetpack and WooCommerce Shipping & Tax plugins have been
* activated.
*/
public static function action_note() {
// Make sure that both plugins are active before actioning the note.
$active_plugin_slugs = PluginsHelper::get_active_plugin_slugs();
$jp_active = in_array( 'jetpack', $active_plugin_slugs, true );
$wcs_active = in_array( 'woocommerce-services', $active_plugin_slugs, true );
if ( ! $jp_active || ! $wcs_active ) {
return;
}
// Action any notes with a matching name.
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
foreach ( $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
if ( $note ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
}
}
/**
* Install the Jetpack and WooCommerce Shipping & Tax plugins in response to the action
* being clicked in the admin note.
*
* @param Note $note The note being actioned.
*/
public function install_jp_and_wcs_plugins( $note ) {
if ( self::NOTE_NAME !== $note->get_name() ) {
return;
}
$this->install_and_activate_plugin( 'jetpack' );
$this->install_and_activate_plugin( 'woocommerce-services' );
}
/**
* Installs and activates the specified plugin.
*
* @param string $plugin The plugin slug.
*/
private function install_and_activate_plugin( $plugin ) {
$install_request = array( 'plugin' => $plugin );
$installer = new \Automattic\WooCommerce\Admin\API\OnboardingPlugins();
$result = $installer->install_plugin( $install_request );
// @todo Use the error statuses to decide whether or not to action the note.
if ( is_wp_error( $result ) ) {
return;
}
$activate_request = array( 'plugins' => $plugin );
$installer->activate_plugins( $activate_request );
}
/**
* Create an alert notification in response to an error installing a plugin.
*
* @param string $slug The slug of the plugin being installed.
*/
public function on_install_error( $slug ) {
// Exit early if we're not installing the Jetpack or the WooCommerce Shipping & Tax plugins.
if ( 'jetpack' !== $slug && 'woocommerce-services' !== $slug ) {
return;
}
self::possibly_add_note();
}
}

View File

@@ -0,0 +1,60 @@
<?php
/**
* WooCommerce Admin Launch Checklist Note.
*
* Adds a note to cover pre-launch checklist items for store owners.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Launch_Checklist
*/
class LaunchChecklist {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-launch-checklist';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if completing the task list or completed 3 tasks in 10 days.
$completed_tasks = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );
$ten_days_in_seconds = 10 * DAY_IN_SECONDS;
if (
! get_option( 'woocommerce_task_list_complete' ) &&
(
count( $completed_tasks ) < 3 ||
self::is_wc_admin_active_in_date_range( 'week-1-4', $ten_days_in_seconds )
)
) {
return;
}
$content = __( 'To make sure you never get that sinking "what did I forget" feeling, we\'ve put together the essential pre-launch checklist.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Ready to launch your store?', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/posts/pre-launch-checklist-the-essentials/?utm_source=inbox&utm_medium=product' );
return $note;
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* WooCommerce Admin note on how to migrate from Magento.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* MagentoMigration
*/
class MagentoMigration {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-magento-migration';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( __CLASS__, 'possibly_add_note' ) );
add_action( 'woocommerce_admin_magento_migration_note', array( __CLASS__, 'save_note' ) );
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( empty( $onboarding_profile ) ) {
return;
}
if (
! isset( $onboarding_profile['other_platform'] ) ||
'magento' !== $onboarding_profile['other_platform']
) {
return;
}
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
WC()->queue()->schedule_single( time() + ( 5 * MINUTE_IN_SECONDS ), 'woocommerce_admin_magento_migration_note' );
}
/**
* Save the note to the database.
*/
public static function save_note() {
$note = self::get_note();
if ( self::note_exists() ) {
return;
}
$note->save();
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'How to Migrate from Magento to WooCommerce', 'woocommerce' ) );
$note->set_content( __( 'Changing platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-migrate-from-magento-to-woocommerce/?utm_source=inbox'
);
return $note;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* WooCommerce Admin Manage orders on the go note.
*
* Adds a note to download the mobile app to manage orders on the go.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Manage_Orders_On_The_Go
*/
class ManageOrdersOnTheGo {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-manage-orders-on-the-go';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only add this note if this store is at least 6 months old.
if ( ! self::is_wc_admin_active_in_date_range( 'month-6+' ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Manage your orders on the go', 'woocommerce' ) );
$note->set_content( __( 'Look for orders, customer info, and process refunds in one click with the Woo app.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}

View File

@@ -0,0 +1,146 @@
<?php
/**
* WooCommerce Admin Jetpack Marketing Note Provider.
*
* Adds notes to the merchant's inbox concerning Jetpack Backup.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Suggest Jetpack Backup to Woo users.
*
* Note: This should probably live in the Jetpack plugin in the future.
*
* @see https://developer.woocommerce.com/2020/10/16/using-the-admin-notes-inbox-in-woocommerce/
*/
class MarketingJetpack {
// Shared Note Traits.
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-marketing-jetpack-backup';
/**
* Product IDs that include Backup.
*/
const BACKUP_IDS = [
2010,
2011,
2012,
2013,
2014,
2015,
2100,
2101,
2102,
2103,
2005,
2006,
2000,
2003,
2001,
2004,
];
/**
* Maybe add a note on Jetpack Backups for Jetpack sites older than a week without Backups.
*/
public static function possibly_add_note() {
/**
* Check if Jetpack is installed.
*/
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
if ( ! in_array( 'jetpack', $installed_plugins, true ) ) {
return;
}
$data_store = \WC_Data_Store::load( 'admin-note' );
// Do we already have this note?
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
// If Jetpack Backups was purchased after the note was created, mark this note as actioned.
if ( self::has_backups() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
return;
}
// Check requirements.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', DAY_IN_SECONDS * 3 ) || ! self::can_be_added() || self::has_backups() ) {
return;
}
// Add note.
$note = self::get_note();
$note->save();
}
/**
* Get the note.
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Protect your WooCommerce Store with Jetpack Backup.', 'woocommerce' ) );
$note->set_content( __( 'Store downtime means lost sales. One-click restores get you back online quickly if something goes wrong.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_layout( 'thumbnail' );
$note->set_image(
WC_ADMIN_IMAGES_FOLDER_URL . '/admin_notes/marketing-jetpack-2x.png'
);
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin-notes' );
$note->add_action(
'jetpack-backup-woocommerce',
__( 'Get backups', 'woocommerce' ),
esc_url( 'https://jetpack.com/upgrade/backup-woocommerce/?utm_source=inbox&utm_medium=automattic_referred&utm_campaign=jp_backup_to_woo' ),
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Check if this blog already has a Jetpack Backups product.
*
* @return boolean Whether or not this blog has backups.
*/
protected static function has_backups() {
$product_ids = [];
$plan = get_option( 'jetpack_active_plan' );
if ( ! empty( $plan ) ) {
$product_ids[] = $plan['product_id'];
}
$products = get_option( 'jetpack_site_products' );
if ( ! empty( $products ) ) {
foreach ( $products as $product ) {
$product_ids[] = $product['product_id'];
}
}
return (bool) array_intersect( self::BACKUP_IDS, $product_ids );
}
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* WooCommerce Admin: Migrate from Shopify to WooCommerce.
*
* Adds a note to ask the client if they want to migrate from Shopify to WooCommerce.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Migrate_From_Shopify.
*/
class MigrateFromShopify {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-migrate-from-shopify';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after two days.
$two_days = 2 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
if (
! isset( $onboarding_profile['setup_client'] ) ||
! isset( $onboarding_profile['selling_venues'] ) ||
! isset( $onboarding_profile['other_platform'] )
) {
return;
}
// Make sure the client is not setup.
if ( $onboarding_profile['setup_client'] ) {
return;
}
// We will show the notification when the client already is selling and is using Shopify.
if (
'other' !== $onboarding_profile['selling_venues'] ||
'shopify' !== $onboarding_profile['other_platform']
) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you want to migrate from Shopify to WooCommerce?', 'woocommerce' ) );
$note->set_content( __( 'Changing eCommerce platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'migrate-from-shopify',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/migrate-from-shopify-to-woocommerce/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* WooCommerce Admin Mobile App Note Provider.
*
* Adds a note to the merchant's inbox showing the benefits of the mobile app.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Mobile_App
*/
class MobileApp {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-mobile-app';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the mobile app note after day 2.
$two_days_in_seconds = 2 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days_in_seconds ) ) {
return;
}
$content = __( 'Install the WooCommerce mobile app to manage orders, receive sales notifications, and view key metrics — wherever you are.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Install Woo mobile app', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_medium=product' );
return $note;
}
}

View File

@@ -0,0 +1,179 @@
<?php
/**
* WooCommerce Admin (Dashboard) New Sales Record Note Provider.
*
* Adds a note to the merchant's inbox when the previous day's sales are a new record.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* New_Sales_Record
*/
class NewSalesRecord {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-new-sales-record';
/**
* Option name for the sales record date in ISO 8601 (YYYY-MM-DD) date.
*/
const RECORD_DATE_OPTION_KEY = 'woocommerce_sales_record_date';
/**
* Option name for the sales record amount.
*/
const RECORD_AMOUNT_OPTION_KEY = 'woocommerce_sales_record_amount';
/**
* Returns the total of yesterday's sales.
*
* @param string $date Date for sales to sum (i.e. YYYY-MM-DD).
* @return floatval
*/
public static function sum_sales_for_date( $date ) {
$order_query = new \WC_Order_Query( array( 'date_created' => $date ) );
$orders = $order_query->get_orders();
$total = 0;
foreach ( (array) $orders as $order ) {
$total += $order->get_total();
}
return $total;
}
/**
* Possibly add a sales record note.
*/
public static function possibly_add_note() {
/**
* Filter to allow for disabling sales record milestones.
*
* @since 3.7.0
*
* @param boolean default true
*/
$sales_record_notes_enabled = apply_filters( 'woocommerce_admin_sales_record_milestone_enabled', true );
if ( ! $sales_record_notes_enabled ) {
return;
}
$yesterday = gmdate( 'Y-m-d', current_time( 'timestamp', 0 ) - DAY_IN_SECONDS );
$total = self::sum_sales_for_date( $yesterday );
// No sales yesterday? Bail.
if ( 0 >= $total ) {
return;
}
$record_date = get_option( self::RECORD_DATE_OPTION_KEY, '' );
$record_amt = floatval( get_option( self::RECORD_AMOUNT_OPTION_KEY, 0 ) );
// No previous entry? Just enter what we have and return without generating a note.
if ( empty( $record_date ) ) {
update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
return;
}
// Otherwise, if yesterdays total bested the record, update AND generate a note.
if ( $total > $record_amt ) {
update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
// We only want one sales record note at any time in the inbox, so we delete any other first.
Notes::delete_notes_with_name( self::NOTE_NAME );
$note = self::get_note_with_record_data( $record_date, $record_amt, $yesterday, $total );
$note->save();
}
}
/**
* Get the note with record data.
*
* @param string $record_date record date Y-m-d.
* @param float $record_amt record amount.
* @param string $yesterday yesterday's date Y-m-d.
* @param string $total total sales for yesterday.
*
* @return Note
*/
public static function get_note_with_record_data( $record_date, $record_amt, $yesterday, $total ) {
// Use F jS (March 7th) format for English speaking countries.
if ( substr( get_user_locale(), 0, 2 ) === 'en' ) {
$date_format = 'F jS';
} else {
// otherwise, fallback to the system date format.
$date_format = get_option( 'date_format' );
}
$formatted_yesterday = date_i18n( $date_format, strtotime( $yesterday ) );
$formatted_total = html_entity_decode( wp_strip_all_tags( wc_price( $total ) ) );
$formatted_record_date = date_i18n( $date_format, strtotime( $record_date ) );
$formatted_record_amt = html_entity_decode( wp_strip_all_tags( wc_price( $record_amt ) ) );
$content = sprintf(
/* translators: 1 and 4: Date (e.g. October 16th), 2 and 3: Amount (e.g. $160.00) */
__( 'Woohoo, %1$s was your record day for sales! Net sales was %2$s beating the previous record of %3$s set on %4$s.', 'woocommerce' ),
$formatted_yesterday,
$formatted_total,
$formatted_record_amt,
$formatted_record_date
);
$content_data = (object) array(
'old_record_date' => $record_date,
'old_record_amt' => $record_amt,
'new_record_date' => $yesterday,
'new_record_amt' => $total,
);
$report_url = '?page=wc-admin&path=/analytics/revenue&period=custom&compare=previous_year&after=' . $yesterday . '&before=' . $yesterday;
// And now, create our new note.
$note = new Note();
$note->set_title( __( 'New sales record!', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( $content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'view-report', __( 'View report', 'woocommerce' ), $report_url );
return $note;
}
/**
* Get the note. This is used for localizing the note.
*
* @return Note
*/
public static function get_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
$content_data = $note->get_content_data();
return self::get_note_with_record_data(
$content_data->old_record_date,
$content_data->old_record_amt,
$content_data->new_record_date,
$content_data->new_record_amt
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
/**
* WooCommerce Admin: Payments reminder note.
*
* Adds a notes to complete the payment methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Onboarding_Payments.
*/
class OnboardingPayments {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-onboarding-payments-reminder';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after five days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 5 * DAY_IN_SECONDS ) ) {
return;
}
// Check to see if any gateways have been added.
$gateways = WC()->payment_gateways->get_available_payment_gateways();
$enabled_gateways = array_filter(
$gateways,
function( $gateway ) {
return 'yes' === $gateway->enabled;
}
);
if ( ! empty( $enabled_gateways ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Start accepting payments on your store!', 'woocommerce' ) );
$note->set_content( __( 'Take payments with the provider thats right for you - choose from 100+ payment gateways for WooCommerce.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'view-payment-gateways',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/product-category/woocommerce-extensions/payment-gateways/?utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED,
true
);
return $note;
}
}

View File

@@ -0,0 +1,98 @@
<?php
/**
* WooCommerce Admin: Start your online clothing store.
*
* Adds a note to ask the client if they are considering starting an online
* clothing store.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Online_Clothing_Store.
*/
class OnlineClothingStore {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-online-clothing-store';
/**
* Returns whether the industries includes fashion-apparel-accessories.
*
* @param array $industries The industries to search.
*
* @return bool Whether the industries includes fashion-apparel-accessories.
*/
private static function is_in_fashion_industry( $industries ) {
foreach ( $industries as $industry ) {
if ( 'fashion-apparel-accessories' === $industry['slug'] ) {
return true;
}
}
return false;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after two days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', 2 * DAY_IN_SECONDS ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// We need to show the notification when the industry is
// fashion/apparel/accessories.
if ( ! isset( $onboarding_profile['industry'] ) ) {
return;
}
if ( ! self::is_in_fashion_industry( $onboarding_profile['industry'] ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Start your online clothing store', 'woocommerce' ) );
$note->set_content( __( 'Starting a fashion website is exciting but it may seem overwhelming as well. In this article, we\'ll walk you through the setup process, teach you to create successful product listings, and show you how to market to your ideal audience.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'online-clothing-store',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/starting-an-online-clothing-store/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}

View File

@@ -0,0 +1,369 @@
<?php
/**
* WooCommerce Admin (Dashboard) Order Milestones Note Provider.
*
* Adds a note to the merchant's inbox when certain order milestones are reached.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Order_Milestones
*/
class OrderMilestones {
/**
* Name of the "other milestones" note.
*/
const NOTE_NAME = 'wc-admin-orders-milestone';
/**
* Option key name to store last order milestone.
*/
const LAST_ORDER_MILESTONE_OPTION_KEY = 'woocommerce_admin_last_orders_milestone';
/**
* Hook to process order milestones.
*/
const PROCESS_ORDERS_MILESTONE_HOOK = 'wc_admin_process_orders_milestone';
/**
* Allowed order statuses for calculating milestones.
*
* @var array
*/
protected $allowed_statuses = array(
'pending',
'processing',
'completed',
);
/**
* Orders count cache.
*
* @var int
*/
protected $orders_count = null;
/**
* Further order milestone thresholds.
*
* @var array
*/
protected $milestones = array(
1,
10,
100,
250,
500,
1000,
5000,
10000,
500000,
1000000,
);
/**
* Delay hook attachment until after the WC post types have been registered.
*
* This is required for retrieving the order count.
*/
public function __construct() {
/**
* Filter Order statuses that will count towards milestones.
*
* @since 3.5.0
*
* @param array $allowed_statuses Order statuses that will count towards milestones.
*/
$this->allowed_statuses = apply_filters( 'woocommerce_admin_order_milestone_statuses', $this->allowed_statuses );
add_action( 'woocommerce_after_register_post_type', array( $this, 'init' ) );
register_deactivation_hook( WC_PLUGIN_FILE, array( $this, 'clear_scheduled_event' ) );
}
/**
* Hook everything up.
*/
public function init() {
if ( ! wp_next_scheduled( self::PROCESS_ORDERS_MILESTONE_HOOK ) ) {
wp_schedule_event( time(), 'hourly', self::PROCESS_ORDERS_MILESTONE_HOOK );
}
add_action( 'wc_admin_installed', array( $this, 'backfill_last_milestone' ) );
add_action( self::PROCESS_ORDERS_MILESTONE_HOOK, array( $this, 'possibly_add_note' ) );
}
/**
* Clear out our hourly milestone hook upon plugin deactivation.
*/
public function clear_scheduled_event() {
wp_clear_scheduled_hook( self::PROCESS_ORDERS_MILESTONE_HOOK );
}
/**
* Get the total count of orders (in the allowed statuses).
*
* @param bool $no_cache Optional. Skip cache.
* @return int Total orders count.
*/
public function get_orders_count( $no_cache = false ) {
if ( $no_cache || is_null( $this->orders_count ) ) {
$status_counts = array_map( 'wc_orders_count', $this->allowed_statuses );
$this->orders_count = array_sum( $status_counts );
}
return $this->orders_count;
}
/**
* Backfill the store's current milestone.
*
* Used to avoid celebrating milestones that were reached before plugin activation.
*/
public function backfill_last_milestone() {
// If the milestone notes have been disabled via filter, bail.
if ( ! $this->are_milestones_enabled() ) {
return;
}
$this->set_last_milestone( $this->get_current_milestone() );
}
/**
* Get the store's last milestone.
*
* @return int Last milestone reached.
*/
public function get_last_milestone() {
return get_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, 0 );
}
/**
* Update the last reached milestone.
*
* @param int $milestone Last milestone reached.
*/
public function set_last_milestone( $milestone ) {
update_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, $milestone );
}
/**
* Calculate the current orders milestone.
*
* Based on the threshold values in $this->milestones.
*
* @return int Current orders milestone.
*/
public function get_current_milestone() {
$milestone_reached = 0;
$orders_count = $this->get_orders_count();
foreach ( $this->milestones as $milestone ) {
if ( $milestone <= $orders_count ) {
$milestone_reached = $milestone;
}
}
return $milestone_reached;
}
/**
* Get the appropriate note title for a given milestone.
*
* @param int $milestone Order milestone.
* @return string Note title for the milestone.
*/
public static function get_note_title_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return __( 'First order received', 'woocommerce' );
case 10:
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return sprintf(
/* translators: Number of orders processed. */
__( 'Congratulations on processing %s orders!', 'woocommerce' ),
wc_format_decimal( $milestone )
);
default:
return '';
}
}
/**
* Get the appropriate note content for a given milestone.
*
* @param int $milestone Order milestone.
* @return string Note content for the milestone.
*/
public static function get_note_content_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return __( 'Congratulations on getting your first order! Now is a great time to learn how to manage your orders.', 'woocommerce' );
case 10:
return __( "You've hit the 10 orders milestone! Look at you go. Browse some WooCommerce success stories for inspiration.", 'woocommerce' );
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return __( 'Another order milestone! Take a look at your Orders Report to review your orders to date.', 'woocommerce' );
default:
return '';
}
}
/**
* Get the appropriate note action for a given milestone.
*
* @param int $milestone Order milestone.
* @return array Note actoion (name, label, query) for the milestone.
*/
public static function get_note_action_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return array(
'name' => 'learn-more',
'label' => __( 'Learn more', 'woocommerce' ),
'query' => 'https://woocommerce.com/document/managing-orders/?utm_source=inbox&utm_medium=product',
);
case 10:
return array(
'name' => 'browse',
'label' => __( 'Browse', 'woocommerce' ),
'query' => 'https://woocommerce.com/success-stories/?utm_source=inbox&utm_medium=product',
);
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return array(
'name' => 'review-orders',
'label' => __( 'Review your orders', 'woocommerce' ),
'query' => '?page=wc-admin&path=/analytics/orders',
);
default:
return array(
'name' => '',
'label' => '',
'query' => '',
);
}
}
/**
* Convenience method to see if the milestone notes are enabled.
*
* @return boolean True if milestone notifications are enabled.
*/
public function are_milestones_enabled() {
/**
* Filter to allow for disabling order milestones.
*
* @since 3.7.0
*
* @param boolean default true
*/
$milestone_notes_enabled = apply_filters( 'woocommerce_admin_order_milestones_enabled', true );
return $milestone_notes_enabled;
}
/**
* Get the note. This is used for localizing the note.
*
* @return Note
*/
public static function get_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
$content_data = $note->get_content_data();
if ( ! isset( $content_data->current_milestone ) ) {
return false;
}
return self::get_note_by_milestone(
$content_data->current_milestone
);
}
/**
* Get the note by milestones.
*
* @param int $current_milestone Current milestone.
*
* @return Note
*/
public static function get_note_by_milestone( $current_milestone ) {
$content_data = (object) array(
'current_milestone' => $current_milestone,
);
$note = new Note();
$note->set_title( self::get_note_title_for_milestone( $current_milestone ) );
$note->set_content( self::get_note_content_for_milestone( $current_milestone ) );
$note->set_content_data( $content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note_action = self::get_note_action_for_milestone( $current_milestone );
$note->add_action( $note_action['name'], $note_action['label'], $note_action['query'] );
return $note;
}
/**
* Checks if a note can and should be added.
*
* @return bool
*/
public function can_be_added() {
// If the milestone notes have been disabled via filter, bail.
if ( ! $this->are_milestones_enabled() ) {
return false;
}
$last_milestone = $this->get_last_milestone();
$current_milestone = $this->get_current_milestone();
if ( $current_milestone <= $last_milestone ) {
return false;
}
return true;
}
/**
* Add milestone notes for other significant thresholds.
*/
public function possibly_add_note() {
if ( ! self::can_be_added() ) {
return;
}
$current_milestone = $this->get_current_milestone();
$this->set_last_milestone( $current_milestone );
// We only want one milestone note at any time.
Notes::delete_notes_with_name( self::NOTE_NAME );
$note = $this->get_note_by_milestone( $current_milestone );
$note->save();
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* WooCommerce Admin Payments More Info Needed Inbox Note Provider
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage;
defined( 'ABSPATH' ) || exit;
/**
* PaymentsMoreInfoNeeded
*/
class PaymentsMoreInfoNeeded {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-payments-more-info-needed';
/**
* Should this note exist?
*/
public static function is_applicable() {
return self::should_display_note();
}
/**
* Returns true if we should display the note.
*
* @return bool
*/
public static function should_display_note() {
// A WooPayments incentive must not be visible.
if ( WcPayWelcomePage::instance()->has_incentive() ) {
return false;
}
// More than 30 days since viewing the welcome page.
$exit_survey_timestamp = get_option( 'wcpay_welcome_page_exit_survey_more_info_needed_timestamp', false );
if ( ! $exit_survey_timestamp ||
( time() - $exit_survey_timestamp < 30 * DAY_IN_SECONDS )
) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::should_display_note() ) {
return;
}
/* translators: %s: Payment provider name. */
$content = sprintf( __( 'We recently asked you if you wanted more information about %s. Run your business and manage your payments in one place with the solution built and supported by WooCommerce.', 'woocommerce' ), 'WooPayments' );
$note = new Note();
/* translators: %s: Payment provider name. */
$note->set_title( sprintf( __( 'Payments made simple with %s', 'woocommerce' ), 'WooPayments' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more here', 'woocommerce' ), 'https://woocommerce.com/payments/' );
return $note;
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* WooCommerce Admin Payment Reminder Me later
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage;
defined( 'ABSPATH' ) || exit;
/**
* PaymentsRemindMeLater
*/
class PaymentsRemindMeLater {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-payments-remind-me-later';
/**
* Should this note exist?
*/
public static function is_applicable() {
return self::should_display_note();
}
/**
* Returns true if we should display the note.
*
* @return bool
*/
public static function should_display_note() {
// A WooPayments incentive must be visible.
if ( ! WcPayWelcomePage::instance()->has_incentive() ) {
return false;
}
// Less than 3 days since viewing welcome page.
$view_timestamp = get_option( 'wcpay_welcome_page_viewed_timestamp', false );
if ( ! $view_timestamp ||
( time() - $view_timestamp < 3 * DAY_IN_SECONDS )
) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::should_display_note() ) {
return;
}
/* translators: 1: Payment provider name. */
$content = sprintf( __( 'Save up to $800 in fees by managing transactions with %1$s. With %1$s, you can securely accept major cards, Apple Pay, and payments in over 100 currencies.', 'woocommerce' ), 'WooPayments' );
$note = new Note();
/* translators: %s: Payment provider name. */
$note->set_title( sprintf( __( 'Save big with %s', 'woocommerce' ), 'WooPayments' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), admin_url( 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' ) );
return $note;
}
}

View File

@@ -0,0 +1,68 @@
<?php
/**
* WooCommerce Admin Performance on mobile note.
*
* Adds a note to download the mobile app, performance on mobile.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Performance_On_Mobile
*/
class PerformanceOnMobile {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-performance-on-mobile';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if this store is at least 9 months old.
$nine_months_in_seconds = MONTH_IN_SECONDS * 9;
if ( ! self::wc_admin_active_for( $nine_months_in_seconds ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Track your store performance on mobile', 'woocommerce' ) );
$note->set_content( __( 'Monitor your sales and high performing products with the Woo app.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* WooCommerce Admin Personalize Your Store Note Provider.
*
* Adds a note to the merchant's inbox prompting them to personalize their store.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Personalize_Store
*/
class PersonalizeStore {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-personalize-store';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only show the note to stores with homepage.
$homepage_id = get_option( 'woocommerce_onboarding_homepage_post_id', false );
if ( ! $homepage_id ) {
return;
}
// Show the note after task list is done.
$is_task_list_complete = get_option( 'woocommerce_task_list_complete', false );
// We want to show the note after day 5.
$five_days_in_seconds = 5 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', $five_days_in_seconds ) && ! $is_task_list_complete ) {
return;
}
$content = __( 'The homepage is one of the most important entry points in your store. When done right it can lead to higher conversions and engagement. Don\'t forget to personalize the homepage that we created for your store during the onboarding.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Personalize your store\'s homepage', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'personalize-homepage', __( 'Personalize homepage', 'woocommerce' ), admin_url( 'post.php?post=' . $homepage_id . '&action=edit' ), Note::E_WC_ADMIN_NOTE_ACTIONED );
return $note;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* WooCommerce Admin Real Time Order Alerts Note.
*
* Adds a note to download the mobile app to monitor store activity.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Real_Time_Order_Alerts
*/
class RealTimeOrderAlerts {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-real-time-order-alerts';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if the store is 3 months old.
if ( ! self::is_wc_admin_active_in_date_range( 'month-3-6' ) ) {
return;
}
// Check that the previous mobile app note was not actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
$content = __( 'Get notifications about store activity, including new orders and product reviews directly on your mobile devices with the Woo app.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Get real-time order alerts anywhere', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product' );
return $note;
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* WooCommerce Admin Scheduled Updates Promotion Note Provider.
*
* Adds a note to the merchant's inbox promoting scheduled updates for analytics.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* ScheduledUpdatesPromotion
*
* @since 10.5.0
*/
class ScheduledUpdatesPromotion {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-scheduled-updates-promotion';
/**
* Name of the option to check.
*/
const OPTION_NAME = 'woocommerce_analytics_scheduled_import';
/**
* Constructor - attach action hooks.
*/
public function __construct() {
add_action( 'woocommerce_note_action_scheduled-updates-enable', array( $this, 'enable_scheduled_updates' ) );
}
/**
* Should this note exist?
*
* @return bool
*/
public static function is_applicable() {
if ( ! Features::is_enabled( 'analytics-scheduled-import' ) ) {
return false;
}
// Get the current option value.
// Note: get_option() returns false when option doesn't exist.
$immediate_import = get_option( self::OPTION_NAME, false );
// Only show to existing sites (false/not set) that haven't migrated yet.
// New sites have the option set during onboarding, so they won't see this.
if ( false !== $immediate_import ) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
if ( ! self::is_applicable() ) {
return null;
}
$note = new Note();
$note->set_title( __( 'Analytics now supports scheduled updates', 'woocommerce' ) );
$note->set_content( __( 'This provides improved performance to your store, enable it in Analytics > Settings.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
// Add "Enable" action with custom handler.
$note->add_action(
'scheduled-updates-enable',
__( 'Enable', 'woocommerce' ),
wc_admin_url(),
Note::E_WC_ADMIN_NOTE_ACTIONED,
true,
__( 'Scheduled updates enabled', 'woocommerce' )
);
return $note;
}
/**
* Enable scheduled updates when the action is triggered.
*
* @param Note $note The note being actioned.
* @return void
*/
public function enable_scheduled_updates( $note ): void {
// Verify this is our note.
if ( self::NOTE_NAME !== $note->get_name() ) {
return;
}
// Update the option to enable scheduled mode.
update_option( self::OPTION_NAME, 'yes' );
}
}

View File

@@ -0,0 +1,85 @@
<?php
/**
* WooCommerce Admin: Selling Online Courses note
*
* Adds a note to encourage selling online courses.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
/**
* Selling_Online_Courses
*/
class SellingOnlineCourses {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-selling-online-courses';
/**
* Attach hooks.
*/
public function __construct() {
add_action(
'update_option_' . OnboardingProfile::DATA_OPTION,
array( $this, 'check_onboarding_profile' ),
10,
3
);
}
/**
* Check to see if the profiler options match before possibly adding note.
*
* @param object $old_value The old option value.
* @param object $value The new option value.
* @param string $option The name of the option.
*/
public static function check_onboarding_profile( $old_value, $value, $option ) {
// Skip adding if this store is in the education/learning industry.
if ( ! isset( $value['industry'] ) ) {
return;
}
$industry_slugs = array_column( $value['industry'], 'slug' );
if ( ! in_array( 'education-and-learning', $industry_slugs, true ) ) {
return;
}
self::possibly_add_note();
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Do you want to sell online courses?', 'woocommerce' ) );
$note->set_content( __( 'Online courses are a great solution for any business that can teach a new skill. Since courses dont require physical product development or shipping, theyre affordable, fast to create, and can generate passive income for years to come. In this article, we provide you more information about selling courses using WooCommerce.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-to-sell-online-courses-wordpress/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* WooCommerce Admin Usage Tracking Opt In Note Provider.
*
* Adds a Usage Tracking Opt In extension note.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use WC_Tracks;
/**
* Tracking_Opt_In
*/
class TrackingOptIn {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-usage-tracking-opt-in';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'woocommerce_note_action_tracking-opt-in', array( $this, 'opt_in_to_tracking' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only show this note to stores that are opted out.
if ( 'yes' === get_option( 'woocommerce_allow_tracking', 'no' ) ) {
return;
}
// We want to show the note after one week.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
/* translators: 1: open link to WooCommerce.com settings, 2: open link to WooCommerce.com tracking documentation, 3: close link tag. */
$content_format = __(
'Gathering usage data allows us to improve WooCommerce. Your store will be considered as we evaluate new features, judge the quality of an update, or determine if an improvement makes sense. You can always visit the %1$sSettings%3$s and choose to stop sharing data. %2$sRead more%3$s about what data we collect.',
'woocommerce'
);
$note_content = sprintf(
$content_format,
'<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced&section=woocommerce_com' ) ) . '" target="_blank">',
'<a href="https://woocommerce.com/usage-tracking?utm_medium=product" target="_blank">',
'</a>'
);
$note = new Note();
$note->set_title( __( 'Help WooCommerce improve with usage tracking', 'woocommerce' ) );
$note->set_content( $note_content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'tracking-opt-in', __( 'Activate usage tracking', 'woocommerce' ), false, Note::E_WC_ADMIN_NOTE_ACTIONED, true, __( 'Usage tracking activated', 'woocommerce' ) );
return $note;
}
/**
* Opt in to usage tracking when note is actioned.
*
* @param Note $note Note being acted upon.
*/
public function opt_in_to_tracking( $note ) {
if ( self::NOTE_NAME === $note->get_name() ) {
// Get the previous value of the tracking.
$prev_value = get_option( 'woocommerce_allow_tracking', 'no' );
// Opt in to tracking and schedule the first data update.
// Same mechanism as in WC_Admin_Setup_Wizard::wc_setup_store_setup_save().
update_option( 'woocommerce_allow_tracking', 'yes' );
// Track woocommerce_allow_tracking_toggled in case was set as 'no' before.
if ( class_exists( 'WC_Tracks' ) && 'no' === $prev_value ) {
WC_Tracks::track_woocommerce_allow_tracking_toggled( $prev_value, 'yes', 'usage_tracking_note' );
}
wp_schedule_single_event( time() + 10, 'woocommerce_tracker_send_event', array( true ) );
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* WooCommerce Admin Unsecured Files Note.
*
* Adds a warning about potentially unsecured files.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
if ( ! class_exists( Note::class ) ) {
class_alias( WC_Admin_Note::class, Note::class );
}
/**
* Unsecured_Report_Files
*/
class UnsecuredReportFiles {
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-remove-unsecured-report-files';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Potentially unsecured files were found in your uploads directory', 'woocommerce' ) );
$note->set_content(
sprintf(
/* translators: 1: opening analytics docs link tag. 2: closing link tag */
__( 'Files that may contain %1$sstore analytics%2$s reports were found in your uploads directory - we recommend assessing and deleting any such files.', 'woocommerce' ),
'<a href="https://woocommerce.com/document/woocommerce-analytics/" target="_blank">',
'</a>'
)
);
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_ERROR );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://developer.woocommerce.com/2021/09/22/important-security-patch-released-in-woocommerce/',
Note::E_WC_ADMIN_NOTE_UNACTIONED,
true
);
$note->add_action(
'dismiss',
__( 'Dismiss', 'woocommerce' ),
wc_admin_url(),
Note::E_WC_ADMIN_NOTE_ACTIONED,
false
);
return $note;
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
$note = self::get_note();
if ( self::note_exists() ) {
return;
}
$note->save();
}
/**
* Check if the note has been previously added.
*/
public static function note_exists() {
$data_store = \WC_Data_Store::load( 'admin-note' );
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
return ! empty( $note_ids );
}
}

View File

@@ -0,0 +1,224 @@
<?php
/**
* WooCommerce Admin WooCommerce Payments Note Provider.
*
* Adds a note to the merchant's inbox showing the benefits of the WooCommerce Payments.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* WooCommerce_Payments
*/
class WooCommercePayments {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-woocommerce-payments';
/**
* Name of the note for use in the database.
*/
const PLUGIN_SLUG = 'woocommerce-payments';
/**
* Name of the note for use in the database.
*/
const PLUGIN_FILE = 'woocommerce-payments/woocommerce-payments.php';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'init', array( $this, 'install_on_action' ) );
add_action( 'wc-admin-woocommerce-payments_add_note', array( $this, 'add_note' ) );
}
/**
* Maybe add a note on WooCommerce Payments for US based sites older than a week without the plugin installed.
*/
public static function possibly_add_note() {
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) || 'US' !== WC()->countries->get_base_country() ) {
return;
}
$data_store = Notes::load_data_store();
// We already have this note? Then mark the note as actioned.
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
// If the WooCommerce Payments plugin was installed after the note was created, make sure it's marked as actioned.
if ( self::is_installed() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
return;
}
$current_date = new \DateTime();
$publish_date = new \DateTime( '2020-04-14' );
if ( $current_date >= $publish_date ) {
$note = self::get_note();
if ( self::can_be_added() ) {
$note->save();
}
return;
} else {
$hook_name = sprintf( '%s_add_note', self::NOTE_NAME );
if ( ! WC()->queue()->get_next( $hook_name ) ) {
WC()->queue()->schedule_single( $publish_date->getTimestamp(), $hook_name );
}
}
}
/**
* Add a note about WooCommerce Payments.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Try the new way to get paid', 'woocommerce' ) );
$note->set_content(
__( 'Securely accept credit and debit cards on your site. Manage transactions without leaving your WordPress dashboard. Only with <strong>WooPayments</strong>.', 'woocommerce' ) .
'<br><br>' .
sprintf(
/* translators: 1: opening link tag, 2: closing tag */
__( 'By clicking "Get started", you agree to our %1$sTerms of Service%2$s', 'woocommerce' ),
'<a href="https://wordpress.com/tos/" target="_blank">',
'</a>'
)
);
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/payments/?utm_medium=product', Note::E_WC_ADMIN_NOTE_UNACTIONED );
$note->add_action( 'get-started', __( 'Get started', 'woocommerce' ), wc_admin_url( '&action=setup-woocommerce-payments' ), Note::E_WC_ADMIN_NOTE_ACTIONED, true );
$note->add_nonce_to_action( 'get-started', 'setup-woocommerce-payments', '' );
// Create the note as "actioned" if the plugin is already installed.
if ( self::is_installed() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
}
return $note;
}
/**
* Check if the WooCommerce Payments plugin is active or installed.
*/
protected static function is_installed() {
if ( defined( 'WC_Payments' ) ) {
return true;
}
include_once ABSPATH . '/wp-admin/includes/plugin.php';
return 0 === validate_plugin( self::PLUGIN_FILE );
}
/**
* Install and activate WooCommerce Payments.
*
* @return boolean Whether the plugin was successfully activated.
*/
private function install_and_activate_wcpay() {
$install_request = array( 'plugins' => self::PLUGIN_SLUG );
$installer = new \Automattic\WooCommerce\Admin\API\Plugins();
$result = $installer->install_plugins( $install_request );
if ( is_wp_error( $result ) ) {
return false;
}
wc_admin_record_tracks_event( 'woocommerce_payments_install', array( 'context' => 'inbox' ) );
$activate_request = array( 'plugins' => self::PLUGIN_SLUG );
$result = $installer->activate_plugins( $activate_request );
if ( is_wp_error( $result ) ) {
return false;
}
return true;
}
/**
* Install & activate WooCommerce Payments plugin, and redirect to setup.
*/
public function install_on_action() {
// TODO: Need to validate this request more strictly since we're taking install actions directly?
if (
! isset( $_GET['page'] ) ||
'wc-admin' !== $_GET['page'] ||
! isset( $_GET['action'] ) ||
'setup-woocommerce-payments' !== $_GET['action']
) {
return;
}
$data_store = Notes::load_data_store();
// We already have this note? Then mark the note as actioned.
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( empty( $note_ids ) ) {
return;
}
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
$action = $note->get_action( 'get-started' );
if ( ! $action ||
( isset( $action->nonce_action ) &&
(
empty( $_GET['_wpnonce'] ) ||
! wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), $action->nonce_action ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
)
)
) {
return;
}
if ( ! current_user_can( 'install_plugins' ) ) {
return;
}
$this->install_and_activate_wcpay();
// WooCommerce Payments is installed at this point, so link straight into the onboarding flow.
$connect_url = add_query_arg(
array(
'wcpay-connect' => '1',
'_wpnonce' => wp_create_nonce( 'wcpay-connect' ),
),
admin_url()
);
wp_safe_redirect( $connect_url );
exit;
}
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* WooCommerce Admin: WooCommerce Subscriptions.
*
* Adds a note to learn more about WooCommerce Subscriptions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
/**
* WooCommerce_Subscriptions.
*/
class WooCommerceSubscriptions {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-woocommerce-subscriptions';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$onboarding_data = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! isset( $onboarding_data['product_types'] ) || ! in_array( 'subscriptions', $onboarding_data['product_types'], true ) ) {
return;
}
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', DAY_IN_SECONDS ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you need more info about WooCommerce Subscriptions?', 'woocommerce' ) );
$note->set_content( __( 'WooCommerce Subscriptions allows you to introduce a variety of subscriptions for physical or virtual products and services. Create product-of-the-month clubs, weekly service subscriptions or even yearly software billing packages. Add sign-up fees, offer free trials, or set expiration periods.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn More', 'woocommerce' ),
'https://woocommerce.com/products/woocommerce-subscriptions/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_UNACTIONED,
true
);
return $note;
}
}

View File

@@ -0,0 +1,440 @@
<?php
/**
* WooCommerce Admin (Dashboard) WooCommerce.com Extension Subscriptions Note Provider.
*
* Adds notes to the merchant's inbox concerning WooCommerce.com extension subscriptions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\PageController;
/**
* Woo_Subscriptions_Notes
*/
class WooSubscriptionsNotes {
const LAST_REFRESH_OPTION_KEY = 'woocommerce_admin-wc-helper-last-refresh';
const NOTE_NAME = 'wc-admin-wc-helper-connection';
const CONNECTION_NOTE_NAME = 'wc-admin-wc-helper-connection'; // deprecated.
const SUBSCRIPTION_NOTE_NAME = 'wc-admin-wc-helper-subscription';
const NOTIFY_WHEN_DAYS_LEFT = 60;
const BUMP_THRESHOLDS = array( 60, 45, 20, 7, 1 ); // days.
/**
* Hook all the things.
*/
public function __construct() {
add_action( 'admin_head', array( $this, 'admin_head' ) );
add_action( 'update_option_woocommerce_helper_data', array( $this, 'update_option_woocommerce_helper_data' ), 10, 2 );
}
/**
* Reacts to changes in the helper option.
*
* @param array $old_value The previous value of the option.
* @param array $value The new value of the option.
*/
public function update_option_woocommerce_helper_data( $old_value, $value ) {
if ( ! is_array( $old_value ) ) {
$old_value = array();
}
if ( ! is_array( $value ) ) {
$value = array();
}
$old_auth = array_key_exists( 'auth', $old_value ) ? $old_value['auth'] : array();
$new_auth = array_key_exists( 'auth', $value ) ? $value['auth'] : array();
$old_token = array_key_exists( 'access_token', $old_auth ) ? $old_auth['access_token'] : '';
$new_token = array_key_exists( 'access_token', $new_auth ) ? $new_auth['access_token'] : '';
// The site just disconnected.
if ( ! empty( $old_token ) && empty( $new_token ) ) {
$this->remove_notes();
return;
}
// The site is connected.
if ( $this->is_connected() ) {
$this->remove_notes();
$this->refresh_subscription_notes();
return;
}
}
/**
* Runs on `admin_head` hook. Checks the connection and refreshes subscription notes on relevant pages.
*/
public function admin_head() {
if ( ! PageController::is_admin_or_embed_page() ) {
// To avoid unnecessarily calling Helper API, we only want to refresh subscription notes,
// if the request is initiated from the wc admin dashboard or a WC related page which includes
// the Activity button in WC header.
return;
}
$this->check_connection();
if ( $this->is_connected() ) {
$refresh_notes = false;
// Did the user just do something on the helper page?.
if ( isset( $_GET['wc-helper-status'] ) ) { // @codingStandardsIgnoreLine.
$refresh_notes = true;
}
// Has it been more than a day since we last checked?
// Note: We do it this way and not wp_scheduled_task since WC_Helper_Options is not loaded for cron.
$time_now_gmt = current_time( 'timestamp', 0 );
$last_refresh = intval( get_option( self::LAST_REFRESH_OPTION_KEY, 0 ) );
if ( $last_refresh + DAY_IN_SECONDS <= $time_now_gmt ) {
update_option( self::LAST_REFRESH_OPTION_KEY, $time_now_gmt );
$refresh_notes = true;
}
if ( $refresh_notes ) {
$this->refresh_subscription_notes();
}
}
}
/**
* Checks the connection. Adds a note (as necessary) if there is no connection.
*/
public function check_connection() {
if ( ! $this->is_connected() ) {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::CONNECTION_NOTE_NAME );
if ( ! empty( $note_ids ) ) {
// We already have a connection note. Exit early.
return;
}
$this->remove_notes();
}
}
/**
* Whether or not we think the site is currently connected to WooCommerce.com.
*
* @return bool
*/
public function is_connected() {
$auth = \WC_Helper_Options::get( 'auth' );
return ( ! empty( $auth['access_token'] ) );
}
/**
* Returns the WooCommerce.com provided site ID for this site.
*
* @return int|false
*/
public function get_connected_site_id() {
if ( ! $this->is_connected() ) {
return false;
}
$auth = \WC_Helper_Options::get( 'auth' );
return absint( $auth['site_id'] );
}
/**
* Returns an array of product_ids whose subscriptions are active on this site.
*
* @return array
*/
public function get_subscription_active_product_ids() {
$site_id = $this->get_connected_site_id();
if ( ! $site_id ) {
return array();
}
$product_ids = array();
if ( $this->is_connected() ) {
try {
$subscriptions = \WC_Helper::get_subscriptions();
} catch ( \Exception $e ) {
$subscriptions = array();
}
foreach ( (array) $subscriptions as $subscription ) {
if ( in_array( $site_id, $subscription['connections'], true ) ) {
$product_ids[] = $subscription['product_id'];
}
}
}
return $product_ids;
}
/**
* Clears all connection or subscription notes.
*/
public function remove_notes() {
Notes::delete_notes_with_name( self::CONNECTION_NOTE_NAME );
Notes::delete_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
}
/**
* Gets the product_id (if any) associated with a note.
*
* @param Note $note The note object to interrogate.
* @return int|false
*/
public function get_product_id_from_subscription_note( &$note ) {
if ( ! is_object( $note ) ) {
return false;
}
$content_data = $note->get_content_data();
if ( property_exists( $content_data, 'product_id' ) ) {
return intval( $content_data->product_id );
}
return false;
}
/**
* Removes notes for product_ids no longer active on this site.
*/
public function prune_inactive_subscription_notes() {
$active_product_ids = $this->get_subscription_active_product_ids();
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
foreach ( (array) $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
$product_id = $this->get_product_id_from_subscription_note( $note );
if ( ! empty( $product_id ) ) {
if ( ! in_array( $product_id, $active_product_ids, true ) ) {
$note->delete();
}
}
}
}
/**
* Finds a note for a given product ID, if the note exists at all.
*
* @param int $product_id The product ID to search for.
* @return Note|false
*/
public function find_note_for_product_id( $product_id ) {
$product_id = intval( $product_id );
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
foreach ( (array) $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
$found_product_id = $this->get_product_id_from_subscription_note( $note );
if ( $product_id === $found_product_id ) {
return $note;
}
}
return false;
}
/**
* Deletes a note for a given product ID, if the note exists at all.
*
* @param int $product_id The product ID to search for.
*/
public function delete_any_note_for_product_id( $product_id ) {
$product_id = intval( $product_id );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$note->delete();
}
}
/**
* Adds or updates a note for an expiring subscription.
*
* @param array $subscription The subscription to work with.
*/
public function add_or_update_subscription_expiring( $subscription ) {
$product_id = $subscription['product_id'];
$product_name = $subscription['product_name'];
$expires = intval( $subscription['expires'] );
$time_now_gmt = current_time( 'timestamp', 0 );
$days_until_expiration = intval( ceil( ( $expires - $time_now_gmt ) / DAY_IN_SECONDS ) );
$note = $this->find_note_for_product_id( $product_id );
// Note: There is no reason this property should not exist. This is just defensive programming.
if ( $note && property_exists( $note->get_content_data(), 'days_until_expiration' ) ) {
$note_days_until_expiration = intval( $note->get_content_data()->days_until_expiration );
if ( $days_until_expiration === $note_days_until_expiration ) {
// Note is already up to date. Bail.
return;
}
// If we have a note and we are at or have crossed a threshold, we should delete
// the old note and create a new one, thereby "bumping" the note to the top of the inbox.
foreach ( (array) self::BUMP_THRESHOLDS as $bump_threshold ) {
if ( ( $note_days_until_expiration > $bump_threshold ) && ( $days_until_expiration <= $bump_threshold ) ) {
$note->delete();
$note = false;
break;
}
}
}
$note_title = sprintf(
/* translators: name of the extension subscription expiring soon */
__( '%s subscription expiring soon', 'woocommerce' ),
$product_name
);
$note_content = sprintf(
/* translators: number of days until the subscription expires */
__( 'Your subscription expires in %d days. Enable autorenew to avoid losing updates and access to support.', 'woocommerce' ),
$days_until_expiration
);
$note_content_data = (object) array(
'product_id' => $product_id,
'product_name' => $product_name,
'expired' => false,
'days_until_expiration' => $days_until_expiration,
);
if ( ! $note ) {
$note = new Note();
}
// Reset everything in case we are repurposing an expired note as an expiring note.
$note->set_title( $note_title );
$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->clear_actions();
$note->add_action(
'enable-autorenew',
__( 'Enable Autorenew', 'woocommerce' ),
'https://woocommerce.com/my-account/my-subscriptions/?utm_medium=product'
);
$note->set_content( $note_content );
$note->set_content_data( $note_content_data );
$note->save();
}
/**
* Adds a note for an expired subscription, or updates an expiring note to expired.
*
* @param array $subscription The subscription to work with.
*/
public function add_or_update_subscription_expired( $subscription ) {
$product_id = $subscription['product_id'];
$product_name = $subscription['product_name'];
$product_page = $subscription['product_url'];
$expires = intval( $subscription['expires'] );
$expires_date = gmdate( 'F jS', $expires );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$note_content_data = $note->get_content_data();
if ( $note_content_data->expired ) {
// We've already got a full fledged expired note for this. Bail.
// Expired notes' content don't change with time.
return;
}
}
$note_title = sprintf(
/* translators: name of the extension subscription that expired */
__( '%s subscription expired', 'woocommerce' ),
$product_name
);
$note_content = sprintf(
/* translators: date the subscription expired, e.g. Jun 7th 2018 */
__( 'Your subscription expired on %s. Get a new subscription to continue receiving updates and access to support.', 'woocommerce' ),
$expires_date
);
$note_content_data = (object) array(
'product_id' => $product_id,
'product_name' => $product_name,
'expired' => true,
'expires' => $expires,
'expires_date' => $expires_date,
);
if ( ! $note ) {
$note = new Note();
}
$note->set_title( $note_title );
$note->set_content( $note_content );
$note->set_content_data( $note_content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->clear_actions();
$note->add_action(
'renew-subscription',
__( 'Renew Subscription', 'woocommerce' ),
$product_page
);
$note->save();
}
/**
* For each active subscription on this site, checks the expiration date and creates/updates/deletes notes.
*/
public function refresh_subscription_notes() {
if ( ! $this->is_connected() ) {
return;
}
$this->prune_inactive_subscription_notes();
try {
$subscriptions = \WC_Helper::get_subscriptions();
} catch ( \Exception $e ) {
$subscriptions = array();
}
$active_product_ids = $this->get_subscription_active_product_ids();
foreach ( (array) $subscriptions as $subscription ) {
// Only concern ourselves with active products.
$product_id = $subscription['product_id'];
if ( ! in_array( $product_id, $active_product_ids, true ) ) {
continue;
}
// If the subscription will auto-renew, clean up and exit.
if ( $subscription['autorenew'] ) {
$this->delete_any_note_for_product_id( $product_id );
continue;
}
// If the subscription is not expiring by the first threshold, clean up and exit.
$first_threshold = DAY_IN_SECONDS * self::BUMP_THRESHOLDS[0];
$expires = intval( $subscription['expires'] );
$time_now_gmt = current_time( 'timestamp', 0 );
if ( $expires > $time_now_gmt + $first_threshold ) {
$this->delete_any_note_for_product_id( $product_id );
continue;
}
// Otherwise, if the subscription can still have auto-renew enabled, let them know that now.
if ( $expires > $time_now_gmt ) {
$this->add_or_update_subscription_expiring( $subscription );
continue;
}
// If we got this far, the subscription has completely expired, let them know.
$this->add_or_update_subscription_expired( $subscription );
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Initializes backend logic for the onboarding process.
*/
class Onboarding {
/**
* Initialize onboarding functionality.
*
* @internal This method is for internal purposes only.
*/
final public static function init() {
OnboardingHelper::instance()->init();
OnboardingIndustries::init();
OnboardingJetpack::instance()->init();
OnboardingMailchimp::instance()->init();
OnboardingProfile::init();
OnboardingSetupWizard::instance()->init();
OnboardingSync::instance()->init();
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* WooCommerce Onboarding Helper
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingHelper {
/**
* Class instance.
*
* @var OnboardingHelper instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
if ( ! is_admin() ) {
return;
}
add_action( 'current_screen', array( $this, 'add_help_tab' ), 60 );
add_action( 'current_screen', array( $this, 'reset_task_list' ) );
add_action( 'current_screen', array( $this, 'reset_extended_task_list' ) );
}
/**
* Update the help tab setup link to reset the onboarding profiler.
*/
public function add_help_tab() {
if ( ! function_exists( 'wc_get_screen_ids' ) ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
return;
}
// Remove the old help tab if it exists.
$help_tabs = $screen->get_help_tabs();
foreach ( $help_tabs as $help_tab ) {
if ( 'woocommerce_onboard_tab' !== $help_tab['id'] ) {
continue;
}
$screen->remove_help_tab( 'woocommerce_onboard_tab' );
}
// Add the new help tab.
$help_tab = array(
'title' => __( 'Setup wizard', 'woocommerce' ),
'id' => 'woocommerce_onboard_tab',
);
$setup_list = TaskLists::get_list( 'setup' );
$extended_list = TaskLists::get_list( 'extended' );
if ( $setup_list ) {
$help_tab['content'] = '<h2>' . __( 'WooCommerce Onboarding', 'woocommerce' ) . '</h2>';
$help_tab['content'] .= '<h3>' . __( 'Profile Setup Wizard', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to access the setup wizard again, please click on the button below.', 'woocommerce' ) . '</p>' .
'<p><a href="' . wc_admin_url( '&path=/setup-wizard' ) . '" class="button button-primary">' . __( 'Setup wizard', 'woocommerce' ) . '</a></p>';
if ( ! $setup_list->is_complete() ) {
$help_tab['content'] .= '<h3>' . __( 'Task List', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
( $setup_list->is_hidden()
? '<p><a href="' . wc_admin_url( '&reset_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
: '<p><a href="' . wc_admin_url( '&reset_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
);
}
}
if ( $extended_list ) {
$help_tab['content'] .= '<h3>' . __( 'Extended task List', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the extended task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
( $extended_list->is_hidden()
? '<p><a href="' . wc_admin_url( '&reset_extended_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
: '<p><a href="' . wc_admin_url( '&reset_extended_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
);
}
$screen->add_help_tab( $help_tab );
}
/**
* Reset the onboarding task list and redirect to the dashboard.
*/
public function reset_task_list() {
if (
! PageController::is_admin_page() ||
! isset( $_GET['reset_task_list'] ) // phpcs:ignore CSRF ok.
) {
return;
}
$task_list = TaskLists::get_list( 'setup' );
if ( ! $task_list ) {
return;
}
$show = 1 === absint( $_GET['reset_task_list'] ); // phpcs:ignore CSRF ok.
$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.
if ( $update ) {
wc_admin_record_tracks_event(
'tasklist_toggled',
array(
'status' => $show ? 'enabled' : 'disabled',
)
);
}
wp_safe_redirect( wc_admin_url() );
exit;
}
/**
* Reset the extended task list and redirect to the dashboard.
*/
public function reset_extended_task_list() {
if (
! PageController::is_admin_page() ||
! isset( $_GET['reset_extended_task_list'] ) // phpcs:ignore CSRF ok.
) {
return;
}
$task_list = TaskLists::get_list( 'extended' );
if ( ! $task_list ) {
return;
}
$show = 1 === absint( $_GET['reset_extended_task_list'] ); // phpcs:ignore CSRF ok.
$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.
if ( $update ) {
wc_admin_record_tracks_event(
'extended_tasklist_toggled',
array(
'status' => $show ? 'disabled' : 'enabled',
)
);
}
wp_safe_redirect( wc_admin_url() );
exit;
}
}

View File

@@ -0,0 +1,93 @@
<?php
/**
* WooCommerce Onboarding Industries
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Logic around onboarding industries.
*/
class OnboardingIndustries {
/**
* Init.
*/
public static function init() {
add_filter( 'woocommerce_admin_onboarding_preloaded_data', array( __CLASS__, 'preload_data' ) );
}
/**
* Get a list of allowed industries for the onboarding wizard.
*
* @return array
*/
public static function get_allowed_industries() {
/* With "use_description" we turn the description input on. With "description_label" we set the input label */
return apply_filters(
'woocommerce_admin_onboarding_industries',
array(
'fashion-apparel-accessories' => array(
'label' => __( 'Fashion, apparel, and accessories', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'health-beauty' => array(
'label' => __( 'Health and beauty', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'electronics-computers' => array(
'label' => __( 'Electronics and computers', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'food-drink' => array(
'label' => __( 'Food and drink', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'home-furniture-garden' => array(
'label' => __( 'Home, furniture, and garden', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'cbd-other-hemp-derived-products' => array(
'label' => __( 'CBD and other hemp-derived products', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'education-and-learning' => array(
'label' => __( 'Education and learning', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'sports-and-recreation' => array(
'label' => __( 'Sports and recreation', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'arts-and-crafts' => array(
'label' => __( 'Arts and crafts', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'other' => array(
'label' => __( 'Other', 'woocommerce' ),
'use_description' => true,
'description_label' => __( 'Description', 'woocommerce' ),
),
)
);
}
/**
* Add preloaded data to onboarding.
*
* @param array $settings Component settings.
* @return array
*/
public static function preload_data( $settings ) {
$settings['onboarding']['industries'] = self::get_allowed_industries();
return $settings;
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* WooCommerce Onboarding Jetpack
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Contains logic around Jetpack setup during onboarding.
*/
class OnboardingJetpack {
/**
* Class instance.
*
* @var OnboardingJetpack instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'woocommerce_admin_plugins_pre_activate', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
add_action( 'woocommerce_admin_plugins_pre_install', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
// Always hook into Jetpack connection even if outside of admin.
add_action( 'jetpack_site_registered', array( $this, 'set_woocommerce_setup_jetpack_opted_in' ) );
}
/**
* Sets the woocommerce_setup_jetpack_opted_in to true when Jetpack connects to WPCOM.
*/
public function set_woocommerce_setup_jetpack_opted_in() {
update_option( 'woocommerce_setup_jetpack_opted_in', true );
}
/**
* Ensure that Jetpack gets installed and activated ahead of WooCommerce Payments
* if both are being installed/activated at the same time.
*
* See: https://github.com/Automattic/woocommerce-payments/issues/1663
* See: https://github.com/Automattic/jetpack/issues/19624
*
* @param array $plugins A list of plugins to install or activate.
*
* @return array
*/
public function activate_and_install_jetpack_ahead_of_wcpay( $plugins ) {
if ( in_array( 'jetpack', $plugins, true ) && in_array( 'woocommerce-payments', $plugins, true ) ) {
array_unshift( $plugins, 'jetpack' );
$plugins = array_unique( $plugins );
}
return $plugins;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* WooCommerce Onboarding Mailchimp
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
/**
* Logic around updating Mailchimp during onboarding.
*/
class OnboardingMailchimp {
/**
* Class instance.
*
* @var OnboardingMailchimp instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'woocommerce_onboarding_profile_data_updated', array( $this, 'on_profile_data_updated' ), 10, 2 );
}
/**
* Reset MailchimpScheduler if profile data is being updated with a new email.
*
* @param array $existing_data Existing option data.
* @param array $updating_data Updating option data.
*/
public function on_profile_data_updated( $existing_data, $updating_data ) {
if (
isset( $existing_data['store_email'] ) &&
isset( $updating_data['store_email'] ) &&
$existing_data['store_email'] !== $updating_data['store_email']
) {
MailchimpScheduler::reset();
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
/**
* WooCommerce Onboarding Products
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Class for handling product types and data around product types.
*/
class OnboardingProducts {
/**
* Name of product data transient.
*
* @var string
*/
const PRODUCT_DATA_TRANSIENT = 'wc_onboarding_product_data';
/**
* Get a list of allowed product types for the onboarding wizard.
*
* @return array
*/
public static function get_allowed_product_types() {
$products = array(
'physical' => array(
'label' => __( 'Physical products', 'woocommerce' ),
'default' => true,
),
'downloads' => array(
'label' => __( 'Downloads', 'woocommerce' ),
),
'subscriptions' => array(
'label' => __( 'Subscriptions', 'woocommerce' ),
),
'memberships' => array(
'label' => __( 'Memberships', 'woocommerce' ),
'product' => 958589,
),
'bookings' => array(
'label' => __( 'Bookings', 'woocommerce' ),
'product' => 390890,
),
'product-bundles' => array(
'label' => __( 'Bundles', 'woocommerce' ),
'product' => 18716,
),
'product-add-ons' => array(
'label' => __( 'Customizable products', 'woocommerce' ),
'product' => 18618,
),
);
$base_location = wc_get_base_location();
$has_cbd_industry = false;
if ( 'US' === $base_location['country'] ) {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! empty( $profile['industry'] ) ) {
$has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true );
}
}
if ( ! Features::is_enabled( 'subscriptions' ) || 'US' !== $base_location['country'] || $has_cbd_industry ) {
$products['subscriptions']['product'] = 27147;
}
return apply_filters( 'woocommerce_admin_onboarding_product_types', $products );
}
/**
* Get dynamic product data from API.
*
* @param array $product_types Array of product types.
* @return array
*/
public static function get_product_data( $product_types ) {
$locale = get_user_locale();
// Transient value is an array of product data keyed by locale.
$transient_value = get_transient( self::PRODUCT_DATA_TRANSIENT );
$transient_value = is_array( $transient_value ) ? $transient_value : array();
$woocommerce_products = $transient_value[ $locale ] ?? false;
if ( false === $woocommerce_products ) {
$woocommerce_products = wp_remote_get(
add_query_arg(
array(
'locale' => $locale,
),
'https://woocommerce.com/wp-json/wccom-extensions/1.0/search'
),
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
if ( is_wp_error( $woocommerce_products ) ) {
return $product_types;
}
$transient_value[ $locale ] = $woocommerce_products;
set_transient( self::PRODUCT_DATA_TRANSIENT, $transient_value, DAY_IN_SECONDS );
}
$data = json_decode( $woocommerce_products['body'] );
$products = array();
$product_data = array();
// Map product data by ID.
if ( isset( $data ) && isset( $data->products ) ) {
foreach ( $data->products as $product_datum ) {
if ( isset( $product_datum->id ) ) {
$products[ $product_datum->id ] = $product_datum;
}
}
}
// Loop over product types and append data.
foreach ( $product_types as $key => $product_type ) {
$product_data[ $key ] = $product_types[ $key ];
if ( isset( $product_type['product'] ) && isset( $products[ $product_type['product'] ] ) ) {
$price = html_entity_decode( $products[ $product_type['product'] ]->price );
$yearly_price = (float) str_replace( '$', '', $price );
$product_data[ $key ]['yearly_price'] = $yearly_price;
$product_data[ $key ]['description'] = $products[ $product_type['product'] ]->excerpt;
$product_data[ $key ]['more_url'] = $products[ $product_type['product'] ]->link;
$product_data[ $key ]['slug'] = strtolower( preg_replace( '~[^\pL\d]+~u', '-', $products[ $product_type['product'] ]->slug ) );
}
}
return $product_data;
}
/**
* Get the allowed product types with the polled data.
*
* @return array
*/
public static function get_product_types_with_data() {
return self::get_product_data( self::get_allowed_product_types() );
}
/**
* Get relevant purchaseable products for the site.
*
* @return array
*/
public static function get_relevant_products() {
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$installed = PluginsHelper::get_installed_plugin_slugs();
$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
$product_data = self::get_product_types_with_data();
$purchaseable = array();
$remaining = array();
foreach ( $product_types as $type ) {
if ( ! isset( $product_data[ $type ]['slug'] ) ) {
continue;
}
$purchaseable[] = $product_data[ $type ];
if ( ! in_array( $product_data[ $type ]['slug'], $installed, true ) ) {
$remaining[] = $product_data[ $type ]['label'];
}
}
return array(
'purchaseable' => $purchaseable,
'remaining' => $remaining,
);
}
}

View File

@@ -0,0 +1,72 @@
<?php
/**
* WooCommerce Onboarding Setup Wizard
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingProfile {
/**
* Profile data option name.
*/
const DATA_OPTION = 'woocommerce_onboarding_profile';
/**
* Option for storing the onboarding profile progress.
*/
const PROGRESS_OPTION = 'woocommerce_onboarding_profile_progress';
/**
* Add onboarding actions.
*/
public static function init() {
add_action( 'update_option_' . self::DATA_OPTION, array( __CLASS__, 'trigger_complete' ), 10, 2 );
}
/**
* Trigger the woocommerce_onboarding_profile_completed action
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public static function trigger_complete( $old_value, $value ) {
if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
return;
}
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
/**
* Action hook fired when the onboarding profile (or onboarding wizard,
* or profiler) is completed.
*
* @since 1.5.0
*/
do_action( 'woocommerce_onboarding_profile_completed' );
}
/**
* Check if the profiler still needs to be completed.
*
* @return bool
*/
public static function needs_completion() {
$onboarding_data = get_option( self::DATA_OPTION, array() );
$is_completed = isset( $onboarding_data['completed'] ) && true === $onboarding_data['completed'];
$is_skipped = isset( $onboarding_data['skipped'] ) && true === $onboarding_data['skipped'];
// @todo When merging to WooCommerce Core, we should set the `completed` flag to true during the upgrade progress.
// https://github.com/woocommerce/woocommerce-admin/pull/2300#discussion_r287237498.
return ! $is_completed && ! $is_skipped;
}
}

View File

@@ -0,0 +1,348 @@
<?php
/**
* WooCommerce Onboarding Setup Wizard
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\ProcessCoreProfilerPluginInstallOptions;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingSetupWizard {
/**
* Class instance.
*
* @var OnboardingSetupWizard instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Add onboarding actions.
*/
public function init() {
// should be placed before is_admin() check as this hook is triggered in AJAX calls.
add_action(
'woocommerce_plugins_install_before',
function ( $slug, $source ) {
$this->install_options_for_core_profiler_plugin_install( $slug, $source );
},
10,
2
);
if ( ! is_admin() ) {
return;
}
// Old settings injection.
// Run after Automattic\WooCommerce\Internal\Admin\Loader.
add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
add_filter( 'woocommerce_admin_preload_settings', array( $this, 'preload_settings' ) );
add_filter( 'admin_body_class', array( $this, 'add_loading_classes' ) );
add_action( 'admin_init', array( $this, 'do_admin_redirects' ) );
add_action( 'current_screen', array( $this, 'redirect_to_profiler' ) );
add_filter( 'woocommerce_show_admin_notice', array( $this, 'remove_old_install_notice' ), 10, 2 );
add_filter( 'admin_viewport_meta', array( $this, 'set_viewport_meta_tag' ) );
}
/**
* Test whether the context of execution comes from async action scheduler.
* Note: this is a polyfill for wc_is_running_from_async_action_scheduler()
* which was introduced in WC 4.0.
*
* @return bool
*/
private function is_running_from_async_action_scheduler() {
if ( function_exists( '\wc_is_running_from_async_action_scheduler' ) ) {
return \wc_is_running_from_async_action_scheduler();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return isset( $_REQUEST['action'] ) && 'as_async_request_queue_runner' === $_REQUEST['action'];
}
/**
* Handle redirects to setup/welcome page after install and updates.
*
* For setup wizard, transient must be present, the user must have access rights, and we must ignore the network/bulk plugin updaters.
*/
public function do_admin_redirects() {
// Don't run this fn from Action Scheduler requests, as it would clear _wc_activation_redirect transient.
// That means OBW would never be shown.
if ( $this->is_running_from_async_action_scheduler() ) {
return;
}
// Setup wizard redirect.
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
if ( get_transient( '_wc_activation_redirect' ) && apply_filters( 'woocommerce_enable_setup_wizard', true ) ) {
$do_redirect = true;
$current_page = isset( $_GET['page'] ) ? wc_clean( wp_unslash( $_GET['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification
$is_onboarding_path = ! isset( $_GET['path'] ) || '/setup-wizard' === wc_clean( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
// On these pages, or during these events, postpone the redirect.
// phpcs:ignore WordPress.WP.Capabilities.Unknown
if ( wp_doing_ajax() || is_network_admin() || ! current_user_can( 'manage_woocommerce' ) ) {
$do_redirect = false;
}
// On these pages, or during these events, disable the redirect.
if (
( 'wc-admin' === $current_page && $is_onboarding_path ) ||
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
apply_filters( 'woocommerce_prevent_automatic_wizard_redirect', false ) ||
isset( $_GET['activate-multi'] ) // phpcs:ignore WordPress.Security.NonceVerification
) {
delete_transient( '_wc_activation_redirect' );
$do_redirect = false;
}
if ( $do_redirect ) {
delete_transient( '_wc_activation_redirect' );
wp_safe_redirect( wc_admin_url() );
exit;
}
}
}
/**
* Trigger the woocommerce_onboarding_profile_completed action
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public function trigger_profile_completed_action( $old_value, $value ) {
if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
return;
}
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
/**
* Action hook fired when the onboarding profile (or onboarding wizard,
* or profiler) is completed.
*
* @since 1.5.0
*/
do_action( 'woocommerce_onboarding_profile_completed' );
}
/**
* Returns true if the profiler should be displayed (not completed and not skipped).
*
* @return bool
*/
private function should_show() {
if ( $this->is_setup_wizard() ) {
return true;
}
return OnboardingProfile::needs_completion();
}
/**
* Redirect to the profiler on homepage if completion is needed.
*/
public function redirect_to_profiler() {
if ( ! $this->is_homepage() || ! OnboardingProfile::needs_completion() ) {
return;
}
wp_safe_redirect( wc_admin_url( '&path=/setup-wizard' ) );
exit;
}
/**
* Check if the current page is the profile wizard.
*
* @return bool
*/
private function is_setup_wizard() {
/* phpcs:disable WordPress.Security.NonceVerification */
return isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
isset( $_GET['path'] ) &&
'/setup-wizard' === $_GET['path'];
/* phpcs: enable */
}
/**
* Check if the current page is the homepage.
*
* @return bool
*/
private function is_homepage() {
/* phpcs:disable WordPress.Security.NonceVerification */
return isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
! isset( $_GET['path'] );
/* phpcs: enable */
}
/**
* Determine if the current page is one of the WC Admin pages.
*
* @return bool
*/
private function is_woocommerce_page() {
$current_page = PageController::get_instance()->get_current_page();
if ( ! $current_page || ! isset( $current_page['path'] ) ) {
return false;
}
return 0 === strpos( $current_page['path'], 'wc-admin' );
}
/**
* Add profiler items to component settings.
*
* @param array $settings Component settings.
*
* @return array
*/
public function component_settings( $settings ) {
$profile = (array) get_option( OnboardingProfile::DATA_OPTION, array() );
$settings['onboarding'] = array(
'profile' => $profile,
);
// Only fetch if the onboarding wizard OR the task list is incomplete or currently shown
// or the current page is one of the WooCommerce Admin pages.
if (
( ! $this->should_show() && ! count( TaskLists::get_visible() )
// phpcs:ignore Generic.CodeAnalysis.RequireExplicitBooleanOperatorPrecedence.MissingParentheses
||
! $this->is_woocommerce_page()
)
) {
return $settings;
}
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
$wccom_auth = \WC_Helper_Options::get( 'auth' );
$profile['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true;
$settings['onboarding']['currencySymbols'] = get_woocommerce_currency_symbols();
$settings['onboarding']['euCountries'] = WC()->countries->get_european_union_countries();
$settings['onboarding']['localeInfo'] = include WC()->plugin_path() . '/i18n/locale-info.php';
$settings['onboarding']['profile'] = $profile;
if ( $this->is_setup_wizard() ) {
$settings['onboarding']['pageCount'] = (int) ( wp_count_posts( 'page' ) )->publish;
$settings['onboarding']['postCount'] = (int) ( wp_count_posts( 'post' ) )->publish;
$settings['onboarding']['isBlockTheme'] = wp_is_block_theme();
}
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
return apply_filters( 'woocommerce_admin_onboarding_preloaded_data', $settings );
}
/**
* Preload WC setting options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_settings( $options ) {
$options[] = 'general';
return $options;
}
/**
* Set the admin full screen class when loading to prevent flashes of unstyled content.
*
* @param bool $classes Body classes.
* @return array
*/
public function add_loading_classes( $classes ) {
/* phpcs:disable WordPress.Security.NonceVerification */
if ( $this->is_setup_wizard() ) {
$classes .= ' woocommerce-admin-full-screen';
}
/* phpcs: enable */
return $classes;
}
/**
* Remove the install notice that prompts the user to visit the old onboarding setup wizard.
*
* @param bool $show Show or hide the notice.
* @param string $notice The slug of the notice.
* @return bool
*/
public function remove_old_install_notice( $show, $notice ) {
if ( 'install' === $notice ) {
return false;
}
return $show;
}
/**
* Set the viewport meta tag for the setup wizard.
*
* @param string $viewport_meta Viewport meta content value.
* @return string Viewport meta content value.
*
* @since 9.0.0
*/
public function set_viewport_meta_tag( $viewport_meta ) {
if ( ! $this->is_setup_wizard() ) {
return $viewport_meta;
}
return 'width=device-width, initial-scale=1.0, maximum-scale=1.0';
}
/**
* Install options for core profiler plugin install.
*
* When a plugin is installed from the core profiler, this method is called to process the install options.
*
* Install options are a list of options that are set for the plugin being installed.
*
* @param string $slug Plugin slug.
* @param string $source Source of the plugin install.
*
* @return void|null
*/
public function install_options_for_core_profiler_plugin_install( $slug, $source ) {
// Only proceed if the plugin install was initiated from the core profiler.
if ( 'core-profiler' !== $source ) {
return;
}
// Retrieve the core profiler spec.
$specs = array_filter( Init::get_specs(), fn( $spec ) => 'obw/core-profiler' === $spec->key );
if ( ! $specs ) {
return null;
}
$install_options = new ProcessCoreProfilerPluginInstallOptions( current( $specs )->plugins, $slug, wc_get_logger() );
$install_options->process_install_options();
}
}

View File

@@ -0,0 +1,152 @@
<?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingSync {
/**
* Class instance.
*
* @var OnboardingSync instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( $this, 'send_profile_data_on_update' ), 10, 2 );
add_action( 'woocommerce_helper_connected', array( $this, 'send_profile_data_on_connect' ) );
if ( ! is_admin() ) {
return;
}
add_action( 'current_screen', array( $this, 'redirect_wccom_install' ) );
}
/**
* Send profile data to WooCommerce.com.
*/
private function send_profile_data() {
if ( 'yes' !== get_option( 'woocommerce_allow_tracking', 'no' ) ) {
return;
}
if ( ! class_exists( '\WC_Helper_API' ) || ! method_exists( '\WC_Helper_API', 'put' ) ) {
return;
}
if ( ! class_exists( '\WC_Helper_Options' ) ) {
return;
}
$auth = \WC_Helper_Options::get( 'auth' );
if ( empty( $auth['access_token'] ) || empty( $auth['access_token_secret'] ) ) {
return false;
}
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
$base_location = wc_get_base_location();
$defaults = array(
'plugins' => 'skipped',
'industry' => array(),
'product_types' => array(),
'product_count' => '0',
'selling_venues' => 'no',
'number_employees' => '1',
'revenue' => 'none',
'other_platform' => 'none',
'business_extensions' => array(),
'theme' => get_stylesheet(),
'setup_client' => false,
'store_location' => $base_location['country'],
'default_currency' => get_woocommerce_currency(),
);
// Prepare industries as an array of slugs if they are in array format.
if ( isset( $profile['industry'] ) && is_array( $profile['industry'] ) ) {
$industry_slugs = array();
foreach ( $profile['industry'] as $industry ) {
$industry_slugs[] = is_array( $industry ) ? $industry['slug'] : $industry;
}
$profile['industry'] = $industry_slugs;
}
$body = wp_parse_args( $profile, $defaults );
\WC_Helper_API::put(
'profile',
array(
'authenticated' => true,
'body' => wp_json_encode( $body ),
'headers' => array(
'Content-Type' => 'application/json',
),
)
);
}
/**
* Send profiler data on profiler change to completion.
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public function send_profile_data_on_update( $old_value, $value ) {
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
$this->send_profile_data();
}
/**
* Send profiler data after a site is connected.
*/
public function send_profile_data_on_connect() {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! isset( $profile['completed'] ) || ! $profile['completed'] ) {
return;
}
$this->send_profile_data();
}
/**
* Redirects the user to the task list if the task list is enabled and finishing a wccom checkout.
*
* @todo Once URL params are added to the redirect, we can check those instead of the referer.
*/
public function redirect_wccom_install() {
$task_list = TaskLists::get_list( 'setup' );
if (
! $task_list ||
$task_list->is_hidden() ||
! isset( $_SERVER['HTTP_REFERER'] ) ||
0 !== strpos( wp_unslash( $_SERVER['HTTP_REFERER'] ), 'https://woocommerce.com/checkout?utm_medium=product' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
) {
return;
}
wp_safe_redirect( wc_admin_url() );
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
/**
* When Custom Order Tables are not the default order store (ie, posts are authoritative), we should take care of
* redirecting requests for the order editor and order admin list table to the equivalent posts-table screens.
*
* If the redirect logic is problematic, it can be unhooked using code like the following example:
*
* remove_action(
* 'admin_page_access_denied',
* array( wc_get_container()->get( COTRedirectionController::class ), 'handle_hpos_admin_requests' )
* );
*/
class COTRedirectionController {
/**
* Add hooks needed to perform our magic.
*/
public function setup(): void {
// Only take action in cases where access to the admin screen would otherwise be denied.
add_action( 'admin_page_access_denied', array( $this, 'handle_hpos_admin_requests' ) );
}
/**
* Listen for denied admin requests and, if they appear to relate to HPOS admin screens, potentially
* redirect the user to the equivalent CPT-driven screens.
*
* @param array|null $query_params The query parameters to use when determining the redirect. If not provided, the $_GET superglobal will be used.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function handle_hpos_admin_requests( $query_params = null ) {
$query_params = is_array( $query_params ) ? $query_params : $_GET;
if ( ! isset( $query_params['page'] ) || 'wc-orders' !== $query_params['page'] ) {
return;
}
$params = wp_unslash( $query_params );
$action = $params['action'] ?? '';
unset( $params['page'] );
if ( 'edit' === $action && isset( $params['id'] ) ) {
$params['post'] = $params['id'];
unset( $params['id'] );
$new_url = add_query_arg( $params, get_admin_url( null, 'post.php' ) );
} elseif ( 'new' === $action ) {
unset( $params['action'] );
$params['post_type'] = 'shop_order';
$new_url = add_query_arg( $params, get_admin_url( null, 'post-new.php' ) );
} else {
// If nonce parameters are present and valid, rebuild them for the CPT admin list table.
if ( isset( $params['_wpnonce'] ) && check_admin_referer( 'bulk-orders' ) ) {
$params['_wp_http_referer'] = get_admin_url( null, 'edit.php?post_type=shop_order' );
$params['_wpnonce'] = wp_create_nonce( 'bulk-posts' );
}
// If an `id` array parameter is present, rename as `post`.
if ( isset( $params['id'] ) && is_array( $params['id'] ) ) {
$params['post'] = $params['id'];
unset( $params['id'] );
}
$params['post_type'] = 'shop_order';
$new_url = add_query_arg( $params, get_admin_url( null, 'edit.php' ) );
}
if ( ! empty( $new_url ) && wp_safe_redirect( $new_url, 301 ) ) {
exit;
}
}
}

View File

@@ -0,0 +1,524 @@
<?php
/**
* Renders order edit page, works with both post and order object.
*/
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\OrderAttribution;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
/**
* Class Edit.
*/
class Edit {
/**
* Screen ID for the edit order screen.
*
* @var string
*/
private $screen_id;
/**
* Instance of the CustomMetaBox class. Used to render meta box for custom meta.
*
* @var CustomMetaBox
*/
private $custom_meta_box;
/**
* Instance of the TaxonomiesMetaBox class. Used to render meta box for taxonomies.
*
* @var TaxonomiesMetaBox
*/
private $taxonomies_meta_box;
/**
* Instance of WC_Order to be used in metaboxes.
*
* @var \WC_Order
*/
private $order;
/**
* Action name that the form is currently handling. Could be new_order or edit_order.
*
* @var string
*/
private $current_action;
/**
* Message to be displayed to the user. Index of message from the messages array registered when declaring shop_order post type.
*
* @var int
*/
private $message;
/**
* Controller for orders page. Used to determine redirection URLs.
*
* @var PageController
*/
private $orders_page_controller;
/**
* Hooks all meta-boxes for order edit page. This is static since this may be called by post edit form rendering.
*
* @param string $screen_id Screen ID.
* @param string $title Title of the page.
*/
public static function add_order_meta_boxes( string $screen_id, string $title ) {
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-data', sprintf( __( '%s data', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Data::output', $screen_id, 'normal', 'high' );
add_meta_box( 'woocommerce-order-items', __( 'Items', 'woocommerce' ), 'WC_Meta_Box_Order_Items::output', $screen_id, 'normal', 'high' );
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-notes', sprintf( __( '%s notes', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Notes::output', $screen_id, 'side', 'default' );
add_meta_box( 'woocommerce-order-downloads', __( 'Downloadable product permissions', 'woocommerce' ) . wc_help_tip( __( 'Note: Permissions for order items will automatically be granted when the order status changes to processing/completed.', 'woocommerce' ) ), 'WC_Meta_Box_Order_Downloads::output', $screen_id, 'normal', 'default' );
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-actions', sprintf( __( '%s actions', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Actions::output', $screen_id, 'side', 'high' );
self::maybe_register_order_attribution( $screen_id, $title );
}
/**
* Hooks metabox save functions for order edit page.
*
* @return void
*/
public static function add_save_meta_boxes() {
/**
* Save Order Meta Boxes.
*
* In order:
* Save the order items.
* Save the order totals.
* Save the order downloads.
* Save order data - also updates status and sends out admin emails if needed. Last to show latest data.
* Save actions - sends out other emails. Last to show latest data.
*/
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Items::save', 10 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Downloads::save', 30, 2 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Data::save', 40 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Actions::save', 50, 2 );
}
/**
* Enqueue necessary scripts for order edit page.
*/
private function enqueue_scripts() {
if ( wp_is_mobile() ) {
wp_enqueue_script( 'jquery-touch-punch' );
}
wp_enqueue_script( 'post' ); // Ensure existing JS libraries are still available for backward compat.
}
/**
* Returns the PageController for this edit form. This method is protected to allow child classes to overwrite the PageController object and return custom links.
*
* @since 8.0.0
*
* @return PageController PageController object.
*/
protected function get_page_controller() {
if ( ! isset( $this->orders_page_controller ) ) {
$this->orders_page_controller = wc_get_container()->get( PageController::class );
}
return $this->orders_page_controller;
}
/**
* Setup hooks, actions and variables needed to render order edit page.
*
* @param \WC_Order $order Order object.
*/
public function setup( \WC_Order $order ) {
$this->order = $order;
$current_screen = get_current_screen();
$current_screen->is_block_editor( false );
$this->screen_id = $current_screen->id;
if ( ! isset( $this->custom_meta_box ) ) {
$this->custom_meta_box = wc_get_container()->get( CustomMetaBox::class );
}
if ( ! isset( $this->taxonomies_meta_box ) ) {
$this->taxonomies_meta_box = wc_get_container()->get( TaxonomiesMetaBox::class );
}
$this->add_save_meta_boxes();
$this->handle_order_update();
$this->add_order_meta_boxes( $this->screen_id, __( 'Order', 'woocommerce' ) );
$this->add_order_specific_meta_box();
$this->add_order_taxonomies_meta_box();
/**
* From wp-admin/includes/meta-boxes.php.
*
* Fires after all built-in meta boxes have been added. Custom metaboxes may be enqueued here.
*
* Note that the documentation for this hook (and for the corresponding 'add_meta_boxes_<SCREEN_ID>' hook)
* suggest that a post type will be supplied for the first parameter, and and an instance of WP_Post will be
* supplied as the second parameter. We are not doing that here, however WordPress itself also deviates from
* this in respect of comments and (though now less relevant) links.
*
* @since 3.8.0.
*/
do_action( 'add_meta_boxes', $this->screen_id, $this->order );
/**
* Provides an opportunity to inject custom meta boxes into the order editor screen. This
* hook is an analog of `add_meta_boxes_<POST_TYPE>` as provided by WordPress core.
*
* @since 7.4.0
*
* @param WC_Order $order The order being edited.
*/
do_action( 'add_meta_boxes_' . $this->screen_id, $this->order );
$this->enqueue_scripts();
}
/**
* Set the current action for the form.
*
* @param string $action Action name.
*/
public function set_current_action( string $action ) {
$this->current_action = $action;
}
/**
* Hooks meta box for order specific meta.
*/
private function add_order_specific_meta_box() {
add_meta_box(
'order_custom',
__( 'Custom Fields', 'woocommerce' ),
array( $this, 'render_custom_meta_box' ),
$this->screen_id,
'normal'
);
}
/**
* Render custom meta box.
*
* @return void
*/
private function add_order_taxonomies_meta_box() {
$this->taxonomies_meta_box->add_taxonomies_meta_boxes( $this->screen_id, $this->order->get_type() );
}
/**
* Register order attribution meta boxes if the feature is enabled.
*
* @since 8.5.0
*
* @param string $screen_id Screen ID.
* @param string $title Title of the page.
*
* @return void
*/
private static function maybe_register_order_attribution( string $screen_id, string $title ) {
/**
* Features controller.
*
* @var FeaturesController $feature_controller
*/
$feature_controller = wc_get_container()->get( FeaturesController::class );
if ( ! $feature_controller->feature_is_enabled( 'order_attribution' ) ) {
return;
}
/**
* Order attribution meta box.
*
* @var OrderAttribution $order_attribution_meta_box
*/
$order_attribution_meta_box = wc_get_container()->get( OrderAttribution::class );
add_meta_box(
'woocommerce-order-source-data',
/* Translators: %s order type name. */
sprintf( __( '%s attribution', 'woocommerce' ), $title ),
function( $post_or_order ) use ( $order_attribution_meta_box ) {
$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order );
if ( $order instanceof WC_Order ) {
$order_attribution_meta_box->output( $order );
}
},
$screen_id,
'side',
'high'
);
// Add customer history meta box if analytics is enabled.
if ( 'yes' !== get_option( 'woocommerce_analytics_enabled' ) ) {
return;
}
if ( ! OrderUtil::is_order_edit_screen() ) {
return;
}
/**
* Customer history meta box.
*
* @var CustomerHistory $customer_history_meta_box
*/
$customer_history_meta_box = wc_get_container()->get( CustomerHistory::class );
add_meta_box(
'woocommerce-customer-history',
__( 'Customer history', 'woocommerce' ),
function ( $post_or_order ) use ( $customer_history_meta_box ) {
$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order );
if ( $order instanceof WC_Order ) {
$customer_history_meta_box->output( $order );
}
},
$screen_id,
'side',
'high'
);
}
/**
* Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
*
* @return void
*/
public function handle_order_update() {
if ( ! isset( $this->order ) ) {
return;
}
if ( 'edit_order' !== sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) ) ) {
return;
}
check_admin_referer( $this->get_order_edit_nonce_action() );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized later on by taxonomies_meta_box object.
$taxonomy_input = isset( $_POST['tax_input'] ) ? wp_unslash( $_POST['tax_input'] ) : null;
$this->taxonomies_meta_box->save_taxonomies( $this->order, $taxonomy_input );
/**
* Save meta for shop order.
*
* @param int Order ID.
* @param \WC_Order Post object.
*
* @since 2.1.0
*/
do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );
$this->custom_meta_box->handle_metadata_changes($this->order);
// Order updated message.
$this->message = 1;
// Claim lock.
$edit_lock = wc_get_container()->get( EditLock::class );
$edit_lock->lock( $this->order );
$this->redirect_order( $this->order );
}
/**
* Helper method to redirect to order edit page.
*
* @since 8.0.0
*
* @param \WC_Order $order Order object.
*/
private function redirect_order( \WC_Order $order ) {
$redirect_to = $this->get_page_controller()->get_edit_url( $order->get_id() );
if ( isset( $this->message ) ) {
$redirect_to = add_query_arg( 'message', $this->message, $redirect_to );
}
wp_safe_redirect(
/**
* Filter the URL used to redirect after an order is updated. Similar to the WP post's `redirect_post_location` filter.
*
* @param string $redirect_to The redirect destination URL.
* @param int $order_id The order ID.
* @param \WC_Order $order The order object.
*
* @since 8.0.0
*/
apply_filters(
'woocommerce_redirect_order_location',
$redirect_to,
$order->get_id(),
$order
)
);
exit;
}
/**
* Helper method to get the name of order edit nonce.
*
* @return string Nonce action name.
*/
private function get_order_edit_nonce_action() {
return 'update-order_' . $this->order->get_id();
}
/**
* Render meta box for order specific meta.
*/
public function render_custom_meta_box() {
$this->custom_meta_box->output( $this->order );
}
/**
* Render order edit page.
*/
public function display() {
/**
* This is used by the order edit page to show messages in the notice fields.
* It should be similar to post_updated_messages filter, i.e.:
* array(
* {order_type} => array(
* 1 => 'Order updated.',
* 2 => 'Custom field updated.',
* ...
* ).
*
* The index to be displayed is computed from the $_GET['message'] variable.
*
* @since 7.4.0.
*/
$messages = apply_filters( 'woocommerce_order_updated_messages', array() );
$message = $this->message;
if ( isset( $_GET['message'] ) ) {
$message = absint( $_GET['message'] );
}
if ( isset( $message ) ) {
$message = $messages[ $this->order->get_type() ][ $message ] ?? false;
}
$this->render_wrapper_start( '', $message );
$this->render_meta_boxes();
$this->render_wrapper_end();
}
/**
* Helper function to render wrapper start.
*
* @param string $notice Notice to display, if any.
* @param string $message Message to display, if any.
*/
private function render_wrapper_start( $notice = '', $message = '' ) {
$post_type = get_post_type_object( $this->order->get_type() );
$edit_page_url = $this->get_page_controller()->get_edit_url( $this->order->get_id() );
$form_action = 'edit_order';
$referer = wp_get_referer();
$new_page_url = $this->get_page_controller()->get_new_page_url( $this->order->get_type() );
?>
<div class="wrap">
<h1 class="wp-heading-inline">
<?php
echo 'new_order' === $this->current_action ? esc_html( $post_type->labels->add_new_item ) : esc_html( $post_type->labels->edit_item );
?>
</h1>
<?php
if ( 'edit_order' === $this->current_action ) {
echo ' <a href="' . esc_url( $new_page_url ) . '" class="page-title-action">' . esc_html( $post_type->labels->add_new ) . '</a>';
}
?>
<hr class="wp-header-end">
<?php
if ( $notice ) :
?>
<div id="notice" class="notice notice-warning"><p
id="has-newer-autosave"><?php echo wp_kses_post( $notice ); ?></p></div>
<?php endif; ?>
<?php if ( $message ) : ?>
<div id="message" class="updated notice notice-success is-dismissible">
<p><?php echo wp_kses_post( $message ); ?></p></div>
<?php
endif;
?>
<form name="order" action="<?php echo esc_url( $edit_page_url ); ?>" method="post" id="order"
<?php
/**
* Fires inside the order edit form tag.
*
* @param \WC_Order $order Order object.
*
* @since 6.9.0
*/
do_action( 'order_edit_form_tag', $this->order );
?>
>
<?php wp_nonce_field( $this->get_order_edit_nonce_action() ); ?>
<?php
/**
* Fires at the top of the order edit form. Can be used as a replacement for edit_form_top hook for HPOS.
*
* @param \WC_Order $order Order object.
*
* @since 8.0.0
*/
do_action( 'order_edit_form_top', $this->order );
wp_nonce_field( 'meta-box-order', 'meta-box-order-nonce', false );
wp_nonce_field( 'closedpostboxes', 'closedpostboxesnonce', false );
?>
<input type="hidden" id="hiddenaction" name="action" value="<?php echo esc_attr( $form_action ); ?>"/>
<?php
$order_status = $this->order->get_status( 'edit' );
?>
<input type="hidden" id="original_order_status" name="original_order_status" value="<?php echo esc_attr( $order_status ); ?>"/>
<input type="hidden" id="original_post_status" name="original_post_status" value="<?php echo esc_attr( wc_is_order_status( 'wc-' . $order_status ) ? 'wc-' . $order_status : $order_status ); ?>"/>
<input type="hidden" id="referredby" name="referredby" value="<?php echo $referer ? esc_url( $referer ) : ''; ?>"/>
<input type="hidden" id="post_ID" name="post_ID" value="<?php echo esc_attr( $this->order->get_id() ); ?>"/>
<div id="poststuff">
<div id="post-body"
class="metabox-holder columns-<?php echo ( 1 === get_current_screen()->get_columns() ) ? '1' : '2'; ?>">
<?php
}
/**
* Helper function to render meta boxes.
*/
private function render_meta_boxes() {
?>
<div id="postbox-container-1" class="postbox-container">
<?php do_meta_boxes( $this->screen_id, 'side', $this->order ); ?>
</div>
<div id="postbox-container-2" class="postbox-container">
<?php
do_meta_boxes( $this->screen_id, 'normal', $this->order );
do_meta_boxes( $this->screen_id, 'advanced', $this->order );
?>
</div>
<?php
}
/**
* Helper function to render wrapper end.
*/
private function render_wrapper_end() {
?>
</div> <!-- /post-body -->
</div> <!-- /poststuff -->
</form>
</div> <!-- /wrap -->
<?php
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
/**
* This class takes care of the edit lock logic when HPOS is enabled.
* For better interoperability with WordPress, edit locks are stored in the same format as posts. That is, as a metadata
* in the order object (key: '_edit_lock') in the format "timestamp:user_id".
*
* @since 7.8.0
*/
class EditLock {
const META_KEY_NAME = '_edit_lock';
/**
* Obtains lock information for a given order. If the lock has expired or it's assigned to an invalid user,
* the order is no longer considered locked.
*
* @param \WC_Order $order Order to check.
* @return bool|array
*/
public function get_lock( \WC_Order $order ) {
$lock = $order->get_meta( self::META_KEY_NAME, true, 'edit' );
if ( ! $lock ) {
return false;
}
$lock = explode( ':', $lock );
if ( 2 !== count( $lock ) ) {
return false;
}
$time = absint( $lock[0] );
$user_id = isset( $lock[1] ) ? absint( $lock[1] ) : 0;
if ( ! $time || ! get_user_by( 'id', $user_id ) ) {
return false;
}
/** This filter is documented in WP's wp-admin/includes/ajax-actions.php */
$time_window = apply_filters( 'wp_check_post_lock_window', 150 ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
if ( time() >= ( $time + $time_window ) ) {
return false;
}
return compact( 'time', 'user_id' );
}
/**
* Checks whether the order is being edited (i.e. locked) by another user.
*
* @param \WC_Order $order Order to check.
* @return bool TRUE if order is locked and currently being edited by another user. FALSE otherwise.
*/
public function is_locked_by_another_user( \WC_Order $order ) : bool {
$lock = $this->get_lock( $order );
return $lock && ( get_current_user_id() !== $lock['user_id'] );
}
/**
* Checks whether the order is being edited by any user.
*
* @param \WC_Order $order Order to check.
* @return boolean TRUE if order is locked and currently being edited by a user. FALSE otherwise.
*/
public function is_locked( \WC_Order $order ) : bool {
return (bool) $this->get_lock( $order );
}
/**
* Assigns an order's edit lock to the current user.
*
* @param \WC_Order $order The order to apply the lock to.
* @return array|bool FALSE if no user is logged-in, an array in the same format as {@see get_lock()} otherwise.
*/
public function lock( \WC_Order $order ) {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
$order->update_meta_data( self::META_KEY_NAME, time() . ':' . $user_id );
$order->save_meta_data();
return $order->get_meta( self::META_KEY_NAME, true, 'edit' );
}
/**
* Hooked to 'heartbeat_received' on the edit order page to refresh the lock on an order being edited by the current user.
*
* @param array $response The heartbeat response to be sent.
* @param array $data Data sent through the heartbeat.
* @return array Response to be sent.
*/
public function refresh_lock_ajax( $response, $data ) {
$order_id = absint( $data['wc-refresh-order-lock'] ?? 0 );
if ( ! $order_id ) {
return $response;
}
unset( $response['wp-refresh-post-lock'] );
$order = wc_get_order( $order_id );
if ( ! $order || ! is_a( $order, \WC_Order::class ) || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
return $response;
}
$response['wc-refresh-order-lock'] = array();
if ( ! $this->is_locked_by_another_user( $order ) ) {
$response['wc-refresh-order-lock']['lock'] = $this->lock( $order );
} else {
$current_lock = $this->get_lock( $order );
$user = get_user_by( 'id', $current_lock['user_id'] );
$response['wc-refresh-order-lock']['error'] = array(
// translators: %s is a user's name.
'message' => sprintf( __( '%s has taken over and is currently editing.', 'woocommerce' ), $user->display_name ),
'user_name' => $user->display_name,
'user_avatar_src' => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 64 ) ) : '',
'user_avatar_src_2x' => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 128 ) ) : '',
);
}
return $response;
}
/**
* Hooked to 'heartbeat_received' on the orders screen to refresh the locked status of orders in the list table.
*
* @param array $response The heartbeat response to be sent.
* @param array $data Data sent through the heartbeat.
* @return array Response to be sent.
*/
public function check_locked_orders_ajax( $response, $data ) {
if ( empty( $data['wc-check-locked-orders'] ) || ! is_array( $data['wc-check-locked-orders'] ) ) {
return $response;
}
$response['wc-check-locked-orders'] = array();
$order_ids = array_unique( array_map( 'absint', $data['wc-check-locked-orders'] ) );
foreach ( $order_ids as $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! is_a( $order, \WC_Order::class ) ) {
continue;
}
if ( ! $this->is_locked_by_another_user( $order ) || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
continue;
}
$response['wc-check-locked-orders'][ $order_id ] = true;
}
return $response;
}
/**
* Outputs HTML for the lock dialog based on the status of the lock on the order (if any).
* Depending on who owns the lock, this could be a message with the chance to take over or a message indicating that
* someone else has taken over the order.
*
* @param \WC_Order $order Order object.
* @return void
*/
public function render_dialog( $order ) {
$lock = $this->get_lock( $order );
$user = $lock ? get_user_by( 'id', $lock['user_id'] ) : false;
$locked = $user && ( get_current_user_id() !== $user->ID );
$edit_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_edit_url( $order->get_id() );
$sendback_url = wp_get_referer();
if ( ! $sendback_url ) {
$sendback_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_base_page_url( $order->get_type() );
}
$sendback_text = __( 'Go back', 'woocommerce' );
?>
<div id="post-lock-dialog" class="notification-dialog-wrap <?php echo $locked ? '' : 'hidden'; ?> order-lock-dialog">
<div class="notification-dialog-background"></div>
<div class="notification-dialog">
<?php if ( $locked ) : ?>
<div class="post-locked-message">
<div class="post-locked-avatar"><?php echo get_avatar( $user->ID, 64 ); ?></div>
<p class="currently-editing wp-tab-first" tabindex="0">
<?php
// translators: %s is a user's name.
echo esc_html( sprintf( __( '%s is currently editing this order. Do you want to take over?', 'woocommerce' ), esc_html( $user->display_name ) ) );
?>
</p>
<p>
<a class="button" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a>
<a class="button button-primary wp-tab-last" href="<?php echo esc_url( add_query_arg( 'claim-lock', '1', wp_nonce_url( $edit_url, 'claim-lock-' . $order->get_id() ) ) ); ?>"><?php esc_html_e( 'Take over', 'woocommerce' ); ?></a>
</p>
</div>
<?php else : ?>
<div class="post-taken-over">
<div class="post-locked-avatar"></div>
<p class="wp-tab-first" tabindex="0">
<span class="currently-editing"></span><br />
</p>
<p><a class="button button-primary wp-tab-last" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a></p>
</div>
<?php endif; ?>
</div>
</div>
<?php
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,488 @@
<?php
/**
* Meta box to edit and add custom meta values for an order.
*/
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use WC_Order;
use WP_Ajax_Response;
/**
* Class CustomMetaBox.
*/
class CustomMetaBox {
/**
* Update nonce shared among different meta rows.
*
* @var string
*/
private $update_nonce;
/**
* Helper method to get formatted meta data array with proper keys. This can be directly fed to `list_meta()` method.
*
* @param \WC_Order $order Order object.
*
* @return array Meta data.
*/
private function get_formatted_order_meta_data( \WC_Order $order ) {
$metadata = $order->get_meta_data();
$metadata_to_list = array();
foreach ( $metadata as $meta ) {
$data = $meta->get_data();
if ( is_protected_meta( $data['key'], 'order' ) ) {
continue;
}
$metadata_to_list[] = array(
'meta_id' => $data['id'],
'meta_key' => $data['key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- False positive, not a meta query.
'meta_value' => maybe_serialize( $data['value'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- False positive, not a meta query.
);
}
return $metadata_to_list;
}
/**
* Renders the meta box to manage custom meta.
*
* @param \WP_Post|\WC_Order $order_or_post Post or order object that we are rendering for.
*/
public function output( $order_or_post ) {
if ( is_a( $order_or_post, \WP_Post::class ) ) {
$order = wc_get_order( $order_or_post );
} else {
$order = $order_or_post;
}
$this->render_custom_meta_form( $this->get_formatted_order_meta_data( $order ), $order );
}
/**
* Helper method to render layout and actual HTML
*
* @param array $metadata_to_list List of metadata to render.
* @param \WC_Order $order Order object.
*/
private function render_custom_meta_form( array $metadata_to_list, \WC_Order $order ) {
?>
<div id="postcustomstuff">
<div id="ajax-response"></div>
<?php
list_meta( $metadata_to_list );
$this->render_meta_form( $order );
?>
</div>
<p>
<?php
printf(
/* translators: 1: opening documentation tag 2: closing documentation tag. */
esc_html( __( 'Custom fields can be used to add extra metadata to an order that you can %1$suse in your theme%2$s.', 'woocommerce' ) ),
'<a href="' . esc_attr__( 'https://wordpress.org/support/article/custom-fields/', 'woocommerce' ) . '">',
'</a>'
);
?>
</p>
<?php
}
/**
* Compute keys to display in autofill when adding new meta key entry in custom meta box.
* Currently, returns empty keys, will be implemented after caching is merged.
*
* @param mixed $deprecated Unused argument. For backwards compatibility.
* @param \WP_Post|\WC_Order $order Order object.
*
* @return array Array of keys to display in autofill.
*/
public function order_meta_keys_autofill( $deprecated, $order ) {
if ( ! is_a( $order, \WC_Order::class ) ) {
return array();
}
/**
* Filters values for the meta key dropdown in the Custom Fields meta box.
*
* Compatibility filter for `postmeta_form_keys` filter.
*
* @since 6.9.0
*
* @param array|null $keys Pre-defined meta keys to be used in place of a postmeta query. Default null.
* @param \WC_Order $order The current post object.
*/
$keys = apply_filters( 'postmeta_form_keys', null, $order );
if ( null === $keys || ! is_array( $keys ) ) {
/**
* Compatibility filter for 'postmeta_form_limit', which filters the number of custom fields to retrieve
* for the drop-down in the Custom Fields meta box.
*
* @since 8.8.0
*
* @param int $limit Number of custom fields to retrieve. Default 30.
*/
$limit = (int) apply_filters( 'postmeta_form_limit', 30 );
$keys = wc_get_container()->get( OrdersTableDataStoreMeta::class )->get_meta_keys( $limit );
}
if ( $keys ) {
natcasesort( $keys );
}
return $keys;
}
/**
* Reimplementation of WP core's `meta_form` function. Renders meta form box.
*
* @param \WC_Order $order WC_Order object.
*
* @return void
*/
public function render_meta_form( \WC_Order $order ) : void {
?>
<p><strong><?php esc_html_e( 'Add New Custom Field:', 'woocommerce' ); ?></strong></p>
<table id="newmeta">
<thead>
<tr>
<th class="left"><label for="metakeyselect"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label></th>
<th><label for="metavalue"><?php esc_html_e( 'Value', 'woocommerce' ); ?></label></th>
</tr>
</thead>
<tbody>
<tr>
<td id="newmetaleft" class="left">
<span id="metakey-search">
<select id="metakeyselect" name="metakeyselect" class="wc-order-metakey-search" data-placeholder="<?php esc_attr_e( 'Add existing', 'woocommerce' ); ?>" data-minimum-input-length="0" data-order_id="<?php echo esc_attr( $order->get_id() ); ?>">
</select>
</span>
<input class="hidden" type="text" id="metakeyinput" name="metakeyinput" value="" aria-label="<?php esc_attr_e( 'New custom field name', 'woocommerce' ); ?>" />
<button type="button" id="newmeta-button" class="button button-small hide-if-no-js" onclick="jQuery('#metakeyinput, #metakeyselect, #enternew, #cancelnew, #metakey-search').toggleClass('hidden');jQuery('#metakeyinput, #metakeyselect').filter(':visible').trigger('focus');">
<span id="enternew"><?php esc_html_e( 'Enter new', 'woocommerce' ); ?></span>
<span id="cancelnew" class="hidden"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></span>
</td>
<td><textarea id="metavalue" name="metavalue" rows="2" cols="25"></textarea>
<?php wp_nonce_field( 'add-meta', '_ajax_nonce-add-meta', false ); ?>
</td>
</tr>
</tbody>
</table>
<div class="submit add-custom-field">
<?php
submit_button(
__( 'Add Custom Field', 'woocommerce' ),
'',
'addmeta',
false,
array(
'id' => 'newmeta-submit',
'data-wp-lists' => 'add:the-list:newmeta',
)
);
?>
</div>
<?php
}
/**
* Helper method to verify order edit permissions.
*
* @param int $order_id Order ID.
*
* @return ?WC_Order WC_Order object if the user can edit the order, die otherwise.
*/
private function verify_order_edit_permission_for_ajax( int $order_id ): ?WC_Order {
if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_others_shop_orders' ) ) {
wp_send_json_error( 'missing_capabilities' );
wp_die();
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_send_json_error( 'invalid_order_id' );
wp_die();
}
return $order;
}
/**
* WP Ajax handler to render the list of unique meta keys asynchronously.
*
* @return void
*/
public function search_metakeys_ajax(): void {
check_ajax_referer( 'search-order-metakeys', 'security' );
if ( ! isset( $_GET['order_id'] ) || ! current_user_can( 'edit_shop_orders' ) ) {
wp_die( -1 );
}
$order_id = intval( $_GET['order_id'] );
$order = wc_get_order( $order_id );
if ( ! is_a( $order, \WC_Order::class ) ) {
wp_die( -1 );
}
$found_order_meta_keys = $this->order_meta_keys_autofill( null, $order );
wp_send_json( $found_order_meta_keys );
}
/**
* Reimplementation of WP core's `wp_ajax_add_meta` method to support order custom meta updates with custom tables.
*/
public function add_meta_ajax() {
if ( ! check_ajax_referer( 'add-meta', '_ajax_nonce-add-meta' ) ) {
wp_send_json_error( 'invalid_nonce' );
wp_die();
}
$order_id = (int) $_POST['order_id'] ?? 0;
$order = $this->verify_order_edit_permission_for_ajax( $order_id );
$select_meta_key = trim( sanitize_text_field( wp_unslash( $_POST['metakeyselect'] ?? '' ) ) );
$input_meta_key = trim( sanitize_text_field( wp_unslash( $_POST['metakeyinput'] ?? '' ) ) );
if ( empty( $_POST['meta'] ) && in_array( $select_meta_key, array( '', '#NONE#' ), true ) && ! $input_meta_key ) {
wp_die( 1 );
}
if ( ! empty( $_POST['meta'] ) ) { // update.
$meta = wp_unslash( $_POST['meta'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitization done below in array_walk.
$this->handle_update_meta( $order, $meta );
} else { // add meta.
$meta_value = sanitize_text_field( wp_unslash( $_POST['metavalue'] ?? '' ) );
$meta_key = $input_meta_key ? $input_meta_key : $select_meta_key;
$this->handle_add_meta( $order, $meta_key, $meta_value );
}
}
/**
* Part of WP Core's `wp_ajax_add_meta`. This is re-implemented to support updating meta for custom tables.
*
* @param WC_Order $order Order object.
* @param string $meta_key Meta key.
* @param string $meta_value Meta value.
*
* @return void
*/
private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
$count = 0;
if ( is_protected_meta( $meta_key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
$metas_for_current_key = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
$meta_ids = wp_list_pluck( $metas_for_current_key, 'id' );
$order->add_meta_data( $meta_key, $meta_value );
$order->save_meta_data();
$metas_for_current_key_with_new = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
$meta_id = 0;
$new_meta_ids = wp_list_pluck( $metas_for_current_key_with_new, 'id' );
$new_meta_ids = array_values( array_diff( $new_meta_ids, $meta_ids ) );
if ( count( $new_meta_ids ) > 0 ) {
$meta_id = $new_meta_ids[0];
}
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
'id' => $meta_id,
'data' => $this->list_meta_row(
array(
'meta_id' => $meta_id,
'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
),
$count
),
'position' => 1,
)
);
$response->send();
}
/**
* Handles updating metadata.
*
* @param WC_Order $order Order object.
* @param array $meta Meta object to update.
*
* @return void
*/
private function handle_update_meta( WC_Order $order, array $meta ) {
if ( ! is_array( $meta ) ) {
wp_send_json_error( 'invalid_meta' );
wp_die();
}
array_walk( $meta, 'sanitize_text_field' );
$mid = (int) key( $meta );
if ( ! $mid ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
$key = $meta[ $mid ]['key'];
$value = $meta[ $mid ]['value'];
if ( is_protected_meta( $key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
if ( '' === trim( $key ) ) {
wp_send_json_error( 'invalid_meta_key' );
wp_die();
}
$count = 0;
$order->update_meta_data( $key, $value, $mid );
$order->save_meta_data();
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
'id' => $mid,
'old_id' => $mid,
'data' => $this->list_meta_row(
array(
'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
'meta_value' => $value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
'meta_id' => $mid,
),
$count
),
'position' => 0,
)
);
$response->send();
}
/**
* Outputs a single row of public meta data in the Custom Fields meta box.
*
* @since 2.5.0
*
* @param array $entry Meta entry.
* @param int $count Sequence number of meta entries.
* @return string
*/
private function list_meta_row( array $entry, int &$count ) : string {
if ( is_protected_meta( $entry['meta_key'], 'post' ) ) {
return '';
}
if ( ! $this->update_nonce ) {
$this->update_nonce = wp_create_nonce( 'add-meta' );
}
$r = '';
++ $count;
if ( is_serialized( $entry['meta_value'] ) ) {
if ( is_serialized_string( $entry['meta_value'] ) ) {
// This is a serialized string, so we should display it.
$entry['meta_value'] = maybe_unserialize( $entry['meta_value'] ); // // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
} else {
// This is a serialized array/object so we should NOT display it.
--$count;
return '';
}
}
$entry['meta_key'] = esc_attr( $entry['meta_key'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
$entry['meta_value'] = esc_textarea( $entry['meta_value'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
$entry['meta_id'] = (int) $entry['meta_id'];
$delete_nonce = wp_create_nonce( 'delete-meta_' . $entry['meta_id'] );
$r .= "\n\t<tr id='meta-{$entry['meta_id']}'>";
$r .= "\n\t\t<td class='left'><label class='screen-reader-text' for='meta-{$entry['meta_id']}-key'>" . __( 'Key', 'woocommerce' ) . "</label><input name='meta[{$entry['meta_id']}][key]' id='meta-{$entry['meta_id']}-key' type='text' size='20' value='{$entry['meta_key']}' />";
$r .= "\n\t\t<div class='submit'>";
$r .= get_submit_button( __( 'Delete', 'woocommerce' ), 'deletemeta small', "deletemeta[{$entry['meta_id']}]", false, array( 'data-wp-lists' => "delete:the-list:meta-{$entry['meta_id']}::_ajax_nonce:$delete_nonce" ) );
$r .= "\n\t\t";
$r .= get_submit_button( __( 'Update', 'woocommerce' ), 'updatemeta small', "meta-{$entry['meta_id']}-submit", false, array( 'data-wp-lists' => "add:the-list:meta-{$entry['meta_id']}::_ajax_nonce-add-meta={$this->update_nonce}" ) );
$r .= '</div>';
$r .= wp_nonce_field( 'change-meta', '_ajax_nonce', false, false );
$r .= '</td>';
$r .= "\n\t\t<td><label class='screen-reader-text' for='meta-{$entry['meta_id']}-value'>" . __( 'Value', 'woocommerce' ) . "</label><textarea name='meta[{$entry['meta_id']}][value]' id='meta-{$entry['meta_id']}-value' rows='2' cols='30'>{$entry['meta_value']}</textarea></td>\n\t</tr>";
return $r;
}
/**
* Reimplementation of WP core's `wp_ajax_delete_meta` method to support order custom meta updates with custom tables.
*
* @return void
*/
public function delete_meta_ajax() {
$meta_id = (int) $_POST['id'] ?? 0;
$order_id = (int) $_POST['order_id'] ?? 0;
if ( ! $meta_id || ! $order_id ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
check_ajax_referer( "delete-meta_$meta_id" );
$order = $this->verify_order_edit_permission_for_ajax( $order_id );
$meta_to_delete = wp_list_filter( $order->get_meta_data(), array( 'id' => $meta_id ) );
if ( empty( $meta_to_delete ) ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
$order->delete_meta_data_by_mid( $meta_id );
if ( $order->save() ) {
wp_die( 1 );
}
wp_die( 0 );
}
/**
* Handle the possible changes in order metadata coming from an order edit page in admin
* (labeled "custom fields" in the UI).
*
* This method expects the $_POST array to contain a 'meta' key that is an associative
* array of [meta item id => [ 'key' => meta item name, 'value' => meta item value ];
* and also to contain (possibly empty) 'metakeyinput' and 'metavalue' keys.
*
* @param WC_Order $order The order to handle.
*/
public function handle_metadata_changes( $order ) {
$has_meta_changes = false;
$order_meta = $order->get_meta_data();
$order_meta =
array_combine(
array_map( fn( $meta ) => $meta->id, $order_meta ),
$order_meta
);
// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
foreach ( ( $_POST['meta'] ?? array() ) as $request_meta_id => $request_meta_data ) {
$request_meta_id = wp_unslash( $request_meta_id );
$request_meta_key = wp_unslash( $request_meta_data['key'] );
$request_meta_value = wp_unslash( $request_meta_data['value'] );
if ( array_key_exists( $request_meta_id, $order_meta ) &&
( $order_meta[ $request_meta_id ]->key !== $request_meta_key || $order_meta[ $request_meta_id ]->value !== $request_meta_value ) ) {
$order->update_meta_data( $request_meta_key, $request_meta_value, $request_meta_id );
$has_meta_changes = true;
}
}
$request_new_key = wp_unslash( $_POST['metakeyinput'] ?? '' );
$request_new_value = wp_unslash( $_POST['metavalue'] ?? '' );
if ( '' !== $request_new_key ) {
$order->add_meta_data( $request_new_key, $request_new_value );
$has_meta_changes = true;
}
// phpcs:enable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
if ( $has_meta_changes ) {
$order->save();
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Admin\API\Reports\Customers\Query as CustomersQuery;
use WC_Order;
/**
* Class CustomerHistory
*
* @since 8.5.0
*/
class CustomerHistory {
/**
* Output the customer history template for the order.
*
* @param WC_Order $order The order object.
*
* @return void
*/
public function output( WC_Order $order ): void {
// No history when adding a new order.
if ( 'auto-draft' === $order->get_status() ) {
return;
}
$customer_history = null;
if ( method_exists( $order, 'get_report_customer_id' ) ) {
$customer_history = $this->get_customer_history( $order->get_report_customer_id() );
}
if ( ! $customer_history ) {
$customer_history = array(
'orders_count' => 0,
'total_spend' => 0,
'avg_order_value' => 0,
);
}
wc_get_template( 'order/customer-history.php', $customer_history );
}
/**
* Get the order history for the customer (data matches Customers report).
*
* @param int $customer_report_id The reports customer ID (not necessarily User ID).
*
* @return array|null Order count, total spend, and average spend per order.
*/
private function get_customer_history( $customer_report_id ): ?array {
$args = array(
'customers' => array( $customer_report_id ),
// If unset, these params have default values that affect the results.
'order_after' => null,
'order_before' => null,
);
$customers_query = new CustomersQuery( $args );
$customer_data = $customers_query->get_data();
return $customer_data->data[0] ?? null;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
use WC_Order;
/**
* Class OrderAttribution
*
* @since 8.5.0
*/
class OrderAttribution {
use OrderAttributionMeta;
/**
* OrderAttribution constructor.
*/
public function __construct() {
$this->set_fields_and_prefix();
}
/**
* Format the meta data for display.
*
* @since 8.5.0
*
* @param array $meta The array of meta data to format.
*
* @return void
*/
public function format_meta_data( array &$meta ) {
if ( array_key_exists( 'device_type', $meta ) ) {
switch ( $meta['device_type'] ) {
case 'Mobile':
$meta['device_type'] = __( 'Mobile', 'woocommerce' );
break;
case 'Tablet':
$meta['device_type'] = __( 'Tablet', 'woocommerce' );
break;
case 'Desktop':
$meta['device_type'] = __( 'Desktop', 'woocommerce' );
break;
default:
$meta['device_type'] = __( 'Unknown', 'woocommerce' );
break;
}
}
}
/**
* Output the attribution data metabox for the order.
*
* @since 8.5.0
*
* @param WC_Order $order The order object.
*
* @return void
*/
public function output( WC_Order $order ) {
$meta = $this->filter_meta_data( $order->get_meta_data() );
$this->format_meta_data( $meta );
// No more details if there is only the origin value - this is for unknown source types.
$has_more_details = array( 'origin' ) !== array_keys( $meta );
// For direct, web admin, mobile app or pos orders, also don't show more details.
$simple_sources = array( 'typein', 'admin', 'mobile_app', 'pos' );
if ( isset( $meta['source_type'] ) && in_array( $meta['source_type'], $simple_sources, true ) ) {
$has_more_details = false;
}
$template_data = array(
'meta' => $meta,
'has_more_details' => $has_more_details,
);
wc_get_template( 'order/attribution-details.php', $template_data );
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* TaxonomiesMetaBox class, renders taxonomy sidebar widget on order edit screen.
*/
class TaxonomiesMetaBox {
/**
* Order Table data store class.
*
* @var OrdersTableDataStore
*/
private $orders_table_data_store;
/**
* Dependency injection init method.
*
* @param OrdersTableDataStore $orders_table_data_store Order Table data store class.
*
* @return void
*/
public function init( OrdersTableDataStore $orders_table_data_store ) {
$this->orders_table_data_store = $orders_table_data_store;
}
/**
* Registers meta boxes to be rendered in order edit screen for taxonomies.
*
* Note: This is re-implementation of part of WP core's `register_and_do_post_meta_boxes` function. Since the code block that add meta box for taxonomies is not filterable, we have to re-implement it.
*
* @param string $screen_id Screen ID.
* @param string $order_type Order type to register meta boxes for.
*
* @return void
*/
public function add_taxonomies_meta_boxes( string $screen_id, string $order_type ) {
include_once ABSPATH . 'wp-admin/includes/meta-boxes.php';
$taxonomies = get_object_taxonomies( $order_type );
// All taxonomies.
foreach ( $taxonomies as $tax_name ) {
$taxonomy = get_taxonomy( $tax_name );
if ( ! $taxonomy->show_ui || false === $taxonomy->meta_box_cb ) {
continue;
}
if ( 'post_categories_meta_box' === $taxonomy->meta_box_cb ) {
$taxonomy->meta_box_cb = array( $this, 'order_categories_meta_box' );
}
if ( 'post_tags_meta_box' === $taxonomy->meta_box_cb ) {
$taxonomy->meta_box_cb = array( $this, 'order_tags_meta_box' );
}
$label = $taxonomy->labels->name;
if ( ! is_taxonomy_hierarchical( $tax_name ) ) {
$tax_meta_box_id = 'tagsdiv-' . $tax_name;
} else {
$tax_meta_box_id = $tax_name . 'div';
}
add_meta_box(
$tax_meta_box_id,
$label,
$taxonomy->meta_box_cb,
$screen_id,
'side',
'core',
array(
'taxonomy' => $tax_name,
'__back_compat_meta_box' => true,
)
);
}
}
/**
* Save handler for taxonomy data.
*
* @param \WC_Abstract_Order $order Order object.
* @param array|null $taxonomy_input Taxonomy input passed from input.
*/
public function save_taxonomies( \WC_Abstract_Order $order, $taxonomy_input ) {
if ( ! isset( $taxonomy_input ) ) {
return;
}
$sanitized_tax_input = $this->sanitize_tax_input( $taxonomy_input );
$sanitized_tax_input = $this->orders_table_data_store->init_default_taxonomies( $order, $sanitized_tax_input );
$this->orders_table_data_store->set_custom_taxonomies( $order, $sanitized_tax_input );
}
/**
* Sanitize taxonomy input by calling sanitize callbacks for each registered taxonomy.
*
* @param array|null $taxonomy_data Nonce verified taxonomy input.
*
* @return array Sanitized taxonomy input.
*/
private function sanitize_tax_input( $taxonomy_data ) : array {
$sanitized_tax_input = array();
if ( ! is_array( $taxonomy_data ) ) {
return $sanitized_tax_input;
}
// Convert taxonomy input to term IDs, to avoid ambiguity.
foreach ( $taxonomy_data as $taxonomy => $terms ) {
$tax_object = get_taxonomy( $taxonomy );
if ( $tax_object && isset( $tax_object->meta_box_sanitize_cb ) ) {
$sanitized_tax_input[ $taxonomy ] = call_user_func_array( $tax_object->meta_box_sanitize_cb, array( $taxonomy, $terms ) );
}
}
return $sanitized_tax_input;
}
/**
* Add the categories meta box to the order screen. This is just a wrapper around the post_categories_meta_box.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $box Meta box args.
*
* @return void
*/
public function order_categories_meta_box( $order, $box ) {
$post = get_post( $order->get_id() );
post_categories_meta_box( $post, $box );
}
/**
* Add the tags meta box to the order screen. This is just a wrapper around the post_tags_meta_box.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $box Meta box args.
*
* @return void
*/
public function order_tags_meta_box( $order, $box ) {
$post = get_post( $order->get_id() );
post_tags_meta_box( $post, $box );
}
}

View File

@@ -0,0 +1,586 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
/**
* Controls the different pages/screens associated to the "Orders" menu page.
*/
class PageController {
/**
* The order type.
*
* @var string
*/
private $order_type = '';
/**
* Instance of the posts redirection controller.
*
* @var PostsRedirectionController
*/
private $redirection_controller;
/**
* Instance of the orders list table.
*
* @var ListTable
*/
private $orders_table;
/**
* Instance of orders edit form.
*
* @var Edit
*/
private $order_edit_form;
/**
* Current action.
*
* @var string
*/
private $current_action = '';
/**
* Order object to be used in edit/new form.
*
* @var \WC_Order
*/
private $order;
/**
* Verify that user has permission to edit orders.
*
* @return void
*/
private function verify_edit_permission() {
if ( 'edit_order' === $this->current_action && ( ! isset( $this->order ) || ! $this->order ) ) {
wp_die( esc_html__( 'You attempted to edit an order that does not exist. Perhaps it was deleted?', 'woocommerce' ) );
}
if ( $this->order->get_type() !== $this->order_type ) {
wp_die( esc_html__( 'Order type mismatch.', 'woocommerce' ) );
}
if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->edit_post, $this->order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to edit this order.', 'woocommerce' ) );
}
if ( 'trash' === $this->order->get_status() ) {
wp_die( esc_html__( 'You cannot edit this item because it is in the Trash. Please restore it and try again.', 'woocommerce' ) );
}
}
/**
* Verify that user has permission to create order.
*
* @return void
*/
private function verify_create_permission() {
if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->publish_posts ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You don\'t have permission to create a new order.', 'woocommerce' ) );
}
if ( isset( $this->order ) ) {
$this->verify_edit_permission();
}
}
/**
* Claims the lock for the order being edited/created (unless it belongs to someone else).
* Also handles the 'claim-lock' action which allows taking over the order forcefully.
*
* @return void
*/
private function handle_edit_lock() {
if ( ! $this->order ) {
return;
}
$edit_lock = wc_get_container()->get( EditLock::class );
$locked = $edit_lock->is_locked_by_another_user( $this->order );
// Take over order?
if ( ! empty( $_GET['claim-lock'] ) && wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'claim-lock-' . $this->order->get_id() ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$edit_lock->lock( $this->order );
wp_safe_redirect( $this->get_edit_url( $this->order->get_id() ) );
exit;
}
if ( ! $locked ) {
$edit_lock->lock( $this->order );
}
add_action(
'admin_footer',
function() use ( $edit_lock ) {
$edit_lock->render_dialog( $this->order );
}
);
}
/**
* Sets up the page controller, including registering the menu item.
*
* @return void
*/
public function setup(): void {
global $plugin_page, $pagenow;
$this->redirection_controller = new PostsRedirectionController( $this );
// Register menu.
if ( 'admin_menu' === current_action() ) {
$this->register_menu();
} else {
add_action( 'admin_menu', 'register_menu', 9 );
}
// Not on an Orders page.
if ( empty( $plugin_page ) || 'admin.php' !== $pagenow || 0 !== strpos( $plugin_page, 'wc-orders' ) ) {
return;
}
$this->set_order_type();
$this->set_action();
$page_suffix = ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type );
$page_name = ( \WC_Admin_Menus::can_view_woocommerce_menu_item() ? 'woocommerce_page_wc-orders' : 'admin_page_wc-orders' ) . $page_suffix;
add_action( "load-{$page_name}", array( $this, 'handle_load_page_action' ) );
add_action( 'admin_title', array( $this, 'set_page_title' ) );
}
/**
* Perform initialization for the current action.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function handle_load_page_action() {
$screen = get_current_screen();
$screen->post_type = $this->order_type;
if ( method_exists( $this, 'setup_action_' . $this->current_action ) ) {
$this->{"setup_action_{$this->current_action}"}();
}
}
/**
* Set the document title for Orders screens to match what it would be with the shop_order CPT.
*
* @param string $admin_title The admin screen title before it's filtered.
*
* @return string The filtered admin title.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function set_page_title( $admin_title ) {
if ( ! $this->is_order_screen( $this->order_type ) ) {
return $admin_title;
}
$wp_order_type = get_post_type_object( $this->order_type );
$labels = get_post_type_labels( $wp_order_type );
if ( $this->is_order_screen( $this->order_type, 'list' ) ) {
$admin_title = sprintf(
// translators: 1: The label for an order type 2: The name of the website.
esc_html__( '%1$s &lsaquo; %2$s &#8212; WordPress', 'woocommerce' ),
esc_html( $labels->name ),
esc_html( get_bloginfo( 'name' ) )
);
} elseif ( $this->is_order_screen( $this->order_type, 'edit' ) ) {
$admin_title = sprintf(
// translators: 1: The label for an order type 2: The title of the order 3: The name of the website.
esc_html__( '%1$s #%2$s &lsaquo; %3$s &#8212; WordPress', 'woocommerce' ),
esc_html( $labels->edit_item ),
absint( $this->order->get_id() ),
esc_html( get_bloginfo( 'name' ) )
);
} elseif ( $this->is_order_screen( $this->order_type, 'new' ) ) {
$admin_title = sprintf(
// translators: 1: The label for an order type 2: The name of the website.
esc_html__( '%1$s &lsaquo; %2$s &#8212; WordPress', 'woocommerce' ),
esc_html( $labels->add_new_item ),
esc_html( get_bloginfo( 'name' ) )
);
}
return $admin_title;
}
/**
* Determines the order type for the current screen.
*
* @return void
*/
private function set_order_type() {
global $plugin_page;
$this->order_type = str_replace( array( 'wc-orders--', 'wc-orders' ), '', $plugin_page );
$this->order_type = empty( $this->order_type ) ? 'shop_order' : $this->order_type;
$wc_order_type = wc_get_order_type( $this->order_type );
$wp_order_type = get_post_type_object( $this->order_type );
if ( ! $wc_order_type || ! $wp_order_type || ! $wp_order_type->show_ui || ! current_user_can( $wp_order_type->cap->edit_posts ) ) {
wp_die();
}
}
/**
* Sets the current action based on querystring arguments. Defaults to 'list_orders'.
*
* @return void
*/
private function set_action(): void {
switch ( isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '' ) {
case 'edit':
$this->current_action = 'edit_order';
break;
case 'new':
$this->current_action = 'new_order';
break;
default:
$this->current_action = 'list_orders';
break;
}
}
/**
* Registers the "Orders" menu.
*
* @return void
*/
public function register_menu(): void {
$order_types = wc_get_order_types( 'admin-menu' );
foreach ( $order_types as $order_type ) {
$post_type = get_post_type_object( $order_type );
add_submenu_page(
\WC_Admin_Menus::can_view_woocommerce_menu_item() ? 'woocommerce' : 'admin.php',
$post_type->labels->name,
$post_type->labels->menu_name,
$post_type->cap->edit_posts,
'wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ),
array( $this, 'output' )
);
}
// In some cases (such as if the authoritative order store was changed earlier in the current request) we
// need an extra step to remove the menu entry for the menu post type.
add_action(
'admin_init',
function() use ( $order_types ) {
foreach ( $order_types as $order_type ) {
remove_submenu_page( 'woocommerce', 'edit.php?post_type=' . $order_type );
}
}
);
}
/**
* Outputs content for the current orders screen.
*
* @return void
*/
public function output(): void {
switch ( $this->current_action ) {
case 'edit_order':
case 'new_order':
$this->order_edit_form->display();
break;
case 'list_orders':
default:
$this->orders_table->prepare_items();
$this->orders_table->display();
break;
}
}
/**
* Handles initialization of the orders list table.
*
* @return void
*/
private function setup_action_list_orders(): void {
$this->orders_table = wc_get_container()->get( ListTable::class );
$this->orders_table->setup(
array(
'order_type' => $this->order_type,
)
);
if ( $this->orders_table->current_action() ) {
$this->orders_table->handle_bulk_actions();
}
$this->strip_http_referer();
}
/**
* Perform a redirect to remove the `_wp_http_referer` and `_wpnonce` strings if present in the URL (see also
* wp-admin/edit.php where a similar process takes place), otherwise the size of this field builds to an
* unmanageable length over time.
*/
private function strip_http_referer(): void {
$current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
$stripped_url = remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), $current_url );
if ( $stripped_url !== $current_url ) {
wp_safe_redirect( $stripped_url );
exit;
}
}
/**
* Prepares the order edit form for creating or editing an order.
*
* @see \Automattic\WooCommerce\Internal\Admin\Orders\Edit.
* @since 8.1.0
*/
private function prepare_order_edit_form(): void {
if ( ! $this->order || ! in_array( $this->current_action, array( 'new_order', 'edit_order' ), true ) ) {
return;
}
$this->order_edit_form = $this->order_edit_form ?? new Edit();
$this->order_edit_form->setup( $this->order );
$this->order_edit_form->set_current_action( $this->current_action );
}
/**
* Handles initialization of the orders edit form.
*
* @return void
*/
private function setup_action_edit_order(): void {
global $theorder;
$this->order = wc_get_order( absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ) );
$this->verify_edit_permission();
$this->handle_edit_lock();
$theorder = $this->order;
$this->prepare_order_edit_form();
}
/**
* Handles initialization of the orders edit form with a new order.
*
* @return void
*/
private function setup_action_new_order(): void {
global $theorder;
$this->verify_create_permission();
$order_class_name = wc_get_order_type( $this->order_type )['class_name'];
if ( ! $order_class_name || ! class_exists( $order_class_name ) ) {
wp_die();
}
$this->order = new $order_class_name();
$this->order->set_object_read( false );
$this->order->set_status( 'auto-draft' );
$this->order->set_created_via( 'admin' );
$this->order->save();
$this->handle_edit_lock();
// Schedule auto-draft cleanup. We re-use the WP event here on purpose.
if ( ! wp_next_scheduled( 'wp_scheduled_auto_draft_delete' ) ) {
wp_schedule_event( time(), 'daily', 'wp_scheduled_auto_draft_delete' );
}
$theorder = $this->order;
$this->prepare_order_edit_form();
}
/**
* Returns the current order type.
*
* @return string
*/
public function get_order_type() {
return $this->order_type;
}
/**
* Helper method to generate a link to the main orders screen.
*
* @return string Orders screen URL.
*/
public function get_orders_url(): string {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
admin_url( 'admin.php?page=wc-orders' ) :
admin_url( 'edit.php?post_type=shop_order' );
}
/**
* Helper method to generate edit link for an order.
*
* @param int $order_id Order ID.
*
* @return string Edit link.
*/
public function get_edit_url( int $order_id ) : string {
if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
return admin_url( 'post.php?post=' . absint( $order_id ) ) . '&action=edit';
}
$order = wc_get_order( $order_id );
// Confirm we could obtain the order object (since it's possible it will not exist, due to a sync issue, or may
// have been deleted in a separate concurrent request).
if ( false === $order ) {
wc_get_logger()->debug(
sprintf(
/* translators: %d order ID. */
__( 'Attempted to determine the edit URL for order %d, however the order does not exist.', 'woocommerce' ),
$order_id
)
);
$order_type = 'shop_order';
} else {
$order_type = $order->get_type();
}
try {
$base_url = $this->get_base_page_url( $order_type );
} catch ( \Exception $e ) {
return '';
}
return add_query_arg(
array(
'action' => 'edit',
'id' => absint( $order_id ),
),
$base_url
);
}
/**
* Helper method to generate a link for creating order.
*
* @param string $order_type The order type. Defaults to 'shop_order'.
* @return string
*/
public function get_new_page_url( $order_type = 'shop_order' ) : string {
$url = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
add_query_arg( 'action', 'new', $this->get_base_page_url( $order_type ) ) :
admin_url( 'post-new.php?post_type=' . $order_type );
return $url;
}
/**
* Helper method to generate a link to the main screen for a custom order type.
*
* @param string $order_type The order type.
*
* @return string
*
* @throws \Exception When an invalid order type is passed.
*/
public function get_base_page_url( $order_type ): string {
$order_types_with_ui = wc_get_order_types( 'admin-menu' );
if ( ! in_array( $order_type, $order_types_with_ui, true ) ) {
// translators: %s is a custom order type.
throw new \Exception( sprintf( __( 'Invalid order type: %s.', 'woocommerce' ), esc_html( $order_type ) ) );
}
return admin_url( 'admin.php?page=wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ) );
}
/**
* Helper method to check if the current admin screen is related to orders.
*
* @param string $type Optional. The order type to check for. Default shop_order.
* @param string $action Optional. The purpose of the screen to check for. 'list', 'edit', or 'new'.
* Leave empty to check for any order screen.
*
* @return bool
*/
public function is_order_screen( $type = 'shop_order', $action = '' ) : bool {
if ( ! did_action( 'current_screen' ) ) {
wc_doing_it_wrong(
__METHOD__,
sprintf(
// translators: %s is the name of a function.
esc_html__( '%s must be called after the current_screen action.', 'woocommerce' ),
esc_html( __METHOD__ )
),
'7.9.0'
);
return false;
}
$valid_types = wc_get_order_types( 'view-order' );
if ( ! in_array( $type, $valid_types, true ) ) {
wc_doing_it_wrong(
__METHOD__,
sprintf(
// translators: %s is the name of an order type.
esc_html__( '%s is not a valid order type.', 'woocommerce' ),
esc_html( $type )
),
'7.9.0'
);
return false;
}
if ( wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
if ( $action ) {
switch ( $action ) {
case 'edit':
$is_action = 'edit_order' === $this->current_action;
break;
case 'list':
$is_action = 'list_orders' === $this->current_action;
break;
case 'new':
$is_action = 'new_order' === $this->current_action;
break;
default:
$is_action = false;
break;
}
}
$type_match = $type === $this->order_type;
$action_match = ! $action || $is_action;
} else {
$screen = get_current_screen();
if ( $action ) {
switch ( $action ) {
case 'edit':
$screen_match = 'post' === $screen->base && filter_input( INPUT_GET, 'post', FILTER_VALIDATE_INT );
break;
case 'list':
$screen_match = 'edit' === $screen->base;
break;
case 'new':
$screen_match = 'post' === $screen->base && 'add' === $screen->action;
break;
default:
$screen_match = false;
break;
}
}
$type_match = $type === $screen->post_type;
$action_match = ! $action || $screen_match;
}
return $type_match && $action_match;
}
}

View File

@@ -0,0 +1,228 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* When {@see OrdersTableDataStore} is in use, this class takes care of redirecting admins from CPT-based URLs
* to the new ones.
*/
class PostsRedirectionController {
/**
* Instance of the PageController class.
*
* @var PageController
*/
private $page_controller;
/**
* Constructor.
*
* @param PageController $page_controller Page controller instance. Used to generate links/URLs.
*/
public function __construct( PageController $page_controller ) {
$this->page_controller = $page_controller;
if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
return;
}
add_action(
'admin_menu',
function () {
$this->maybe_update_menu_items();
},
9999
);
add_action(
'load-edit.php',
function() {
$this->maybe_redirect_to_orders_page();
}
);
add_action(
'load-post-new.php',
function() {
$this->maybe_redirect_to_new_order_page();
}
);
add_action(
'load-post.php',
function() {
$this->maybe_redirect_to_edit_order_page();
}
);
}
/**
* If needed, performs a redirection to the main orders page.
*
* @return void
*/
private function maybe_redirect_to_orders_page(): void {
$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
return;
}
// Respect query args, except for 'post_type'.
$query_args = wp_unslash( $_GET );
$action = $query_args['action'] ?? '';
$posts = $query_args['post'] ?? array();
unset( $query_args['post_type'], $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );
// Remap 'post_status' arg.
if ( isset( $query_args['post_status'] ) ) {
$query_args['status'] = $query_args['post_status'];
unset( $query_args['post_status'] );
}
$new_url = $this->page_controller->get_base_page_url( $post_type );
$new_url = add_query_arg( $query_args, $new_url );
// Handle bulk actions.
if ( $action && in_array( $action, array( 'trash', 'untrash', 'delete', 'mark_processing', 'mark_on-hold', 'mark_completed', 'mark_cancelled' ), true ) ) {
check_admin_referer( 'bulk-posts' );
$new_url = add_query_arg(
array(
'action' => $action,
'id' => $posts,
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),
$new_url
);
}
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* If needed, performs a redirection to the new order page.
*
* @return void
*/
private function maybe_redirect_to_new_order_page(): void {
$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
return;
}
// Respect query args, except for 'post_type'.
$query_args = wp_unslash( $_GET );
unset( $query_args['post_type'] );
$new_url = $this->page_controller->get_new_page_url( $post_type );
$new_url = add_query_arg( $query_args, $new_url );
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* If needed, performs a redirection to the edit order page.
*
* @return void
*/
private function maybe_redirect_to_edit_order_page(): void {
$post_id = absint( $_GET['post'] ?? 0 );
if ( ! $post_id ) {
return;
}
$redirect_from_types = wc_get_order_types( 'admin-menu' );
$redirect_from_types[] = 'shop_order_placehold';
$post_type = get_post_type( $post_id );
$order_type = $post_type ? $post_type : OrderUtil::get_order_type( $post_id );
if ( ! in_array( $order_type, $redirect_from_types, true ) || ! isset( $_GET['action'] ) ) {
return;
}
// Respect query args, except for 'post'.
$query_args = wp_unslash( $_GET );
$action = $query_args['action'];
unset( $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );
$new_url = '';
switch ( $action ) {
case 'edit':
$new_url = $this->page_controller->get_edit_url( $post_id );
break;
case 'trash':
case 'untrash':
case 'delete':
// Re-generate nonce if validation passes.
check_admin_referer( $action . '-post_' . $post_id );
$new_url = add_query_arg(
array(
'action' => $action,
'order' => array( $post_id ),
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),
$this->page_controller->get_orders_url()
);
break;
default:
break;
}
if ( ! $new_url ) {
return;
}
$new_url = add_query_arg( $query_args, $new_url );
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* Rewrites legacy post type menu items to point to the HPOS orders page when the main WooCommerce menu is not visible.
*
* @since 10.3.0
*/
private function maybe_update_menu_items(): void {
global $pagenow, $submenu;
// Do not conflict with CPT > HPOS redirection.
if ( 'edit.php' === $pagenow && in_array( $_GET['post_type'] ?? '', wc_get_order_types( 'admin-menu' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
if ( \WC_Admin_Menus::can_view_woocommerce_menu_item() ) {
return;
}
$post_types = array_filter( array_map( 'get_post_type_object', wc_get_order_types( 'admin-menu' ) ) );
foreach ( $post_types as $post_type ) {
if ( ! current_user_can( $post_type->cap->edit_posts ) || ! isset( $submenu[ 'edit.php?post_type=' . $post_type->name ] ) ) {
continue;
}
$post_type_menu = &$submenu[ 'edit.php?post_type=' . $post_type->name ];
$menu_indexes = array_flip( array_map( fn( $x ) => $x[2], $post_type_menu ) );
// Rewrite URL for the legacy menu item.
$post_type_menu[ $menu_indexes[ 'edit.php?post_type=' . $post_type->name ] ][2] = $this->page_controller->get_base_page_url( $post_type->name );
// Hide the legacy "Add New" menu item.
unset( $post_type_menu[ $menu_indexes[ "post-new.php?post_type={$post_type->name}" ] ] );
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
/**
* Abstract class for product form components.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* Component class.
*/
abstract class Component {
/**
* Product Component traits.
*/
use ComponentTrait;
/**
* Component additional arguments.
*
* @var array
*/
protected $additional_args;
/**
* Constructor
*
* @param string $id Component id.
* @param string $plugin_id Plugin id.
* @param array $additional_args Array containing additional arguments.
*/
public function __construct( $id, $plugin_id, $additional_args ) {
$this->id = $id;
$this->plugin_id = $plugin_id;
$this->additional_args = $additional_args;
}
/**
* Component arguments.
*
* @return array
*/
public function get_additional_args() {
return $this->additional_args;
}
/**
* Component arguments.
*
* @param string $key key of argument.
* @return mixed
*/
public function get_additional_argument( $key ) {
return self::get_argument_from_path( $this->additional_args, $key );
}
/**
* Get the component as JSON.
*
* @return array
*/
public function get_json() {
return array_merge(
array(
'id' => $this->get_id(),
'plugin_id' => $this->get_plugin_id(),
),
$this->get_additional_args()
);
}
/**
* Sorting function for product form component.
*
* @param Component $a Component a.
* @param Component $b Component b.
* @param array $sort_by key and order to sort by.
* @return int
*/
public static function sort( $a, $b, $sort_by = array() ) {
$key = $sort_by['key'];
$a_val = $a->get_additional_argument( $key );
$b_val = $b->get_additional_argument( $key );
if ( 'asc' === $sort_by['order'] ) {
return $a_val <=> $b_val;
} else {
return $b_val <=> $a_val;
}
}
/**
* Gets argument by dot notation path.
*
* @param array $arguments Arguments array.
* @param string $path Path for argument key.
* @param string $delimiter Path delimiter, default: '.'.
* @return mixed|null
*/
public static function get_argument_from_path( $arguments, $path, $delimiter = '.' ) {
$path_keys = explode( $delimiter, $path );
$num_keys = false !== $path_keys ? count( $path_keys ) : 0;
$val = $arguments;
for ( $i = 0; $i < $num_keys; $i++ ) {
$key = $path_keys[ $i ];
if ( array_key_exists( $key, $val ) ) {
$val = $val[ $key ];
} else {
$val = null;
break;
}
}
return $val;
}
/**
* Array of required arguments.
*
* @var array
*/
protected $required_arguments = array();
/**
* Get missing arguments of args array.
*
* @param array $args field arguments.
* @return array
*/
public function get_missing_arguments( $args ) {
return array_values(
array_filter(
$this->required_arguments,
function( $arg_key ) use ( $args ) {
return null === self::get_argument_from_path( $args, $arg_key );
}
)
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* Product Form Traits
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
defined( 'ABSPATH' ) || exit;
/**
* ComponentTrait class.
*/
trait ComponentTrait {
/**
* Component ID.
*
* @var string
*/
protected $id;
/**
* Plugin ID.
*
* @var string
*/
protected $plugin_id;
/**
* Product form component location.
*
* @var string
*/
protected $location;
/**
* Product form component order.
*
* @var number
*/
protected $order;
/**
* Return id.
*
* @return string
*/
public function get_id() {
return $this->id;
}
/**
* Return plugin id.
*
* @return string
*/
public function get_plugin_id() {
return $this->plugin_id;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Handles product form field related methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* Field class.
*/
class Field extends Component {
/**
* Constructor
*
* @param string $id Field id.
* @param string $plugin_id Plugin id.
* @param array $additional_args Array containing the necessary arguments.
* $args = array(
* 'type' => (string) Field type. Required.
* 'section' => (string) Field location. Required.
* 'order' => (int) Field order.
* 'properties' => (array) Field properties.
* ).
* @throws \Exception If there are missing arguments.
*/
public function __construct( $id, $plugin_id, $additional_args ) {
parent::__construct( $id, $plugin_id, $additional_args );
$this->required_arguments = array(
'type',
'section',
'properties.name',
'properties.label',
);
$missing_arguments = self::get_missing_arguments( $additional_args );
if ( count( $missing_arguments ) > 0 ) {
throw new \Exception(
sprintf(
/* translators: 1: Missing arguments list. */
esc_html__( 'You are missing required arguments of WooCommerce ProductForm Field: %1$s', 'woocommerce' ),
join( ', ', $missing_arguments )
)
);
}
}
}

View File

@@ -0,0 +1,289 @@
<?php
/**
* WooCommerce Product Form Factory
*
* @package Woocommerce ProductForm
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
use WP_Error;
/**
* Factory that contains logic for the WooCommerce Product Form.
*/
class FormFactory {
/**
* Class instance.
*
* @var Form instance
*/
protected static $instance = null;
/**
* Store form fields.
*
* @var array
*/
protected static $form_fields = array();
/**
* Store form cards.
*
* @var array
*/
protected static $form_subsections = array();
/**
* Store form sections.
*
* @var array
*/
protected static $form_sections = array();
/**
* Store form tabs.
*
* @var array
*/
protected static $form_tabs = array();
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() { }
/**
* Adds a field to the product form.
*
* @param string $id Field id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'type' => (string) Field type. Required.
* 'section' => (string) Field location. Required.
* 'order' => (int) Field order.
* 'properties' => (array) Field properties.
* 'name' => (string) Field name.
* ).
* @return Field|WP_Error New field or WP_Error.
*/
public static function add_field( $id, $plugin_id, $args ) {
$new_field = self::create_item( 'field', 'Field', $id, $plugin_id, $args );
if ( is_wp_error( $new_field ) ) {
return $new_field;
}
self::$form_fields[ $id ] = $new_field;
return $new_field;
}
/**
* Adds a Subsection to the product form.
*
* @param string $id Subsection id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* @return Subsection|WP_Error New subsection or WP_Error.
*/
public static function add_subsection( $id, $plugin_id, $args = array() ) {
$new_subsection = self::create_item( 'subsection', 'Subsection', $id, $plugin_id, $args );
if ( is_wp_error( $new_subsection ) ) {
return $new_subsection;
}
self::$form_subsections[ $id ] = $new_subsection;
return $new_subsection;
}
/**
* Adds a section to the product form.
*
* @param string $id Card id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* @return Section|WP_Error New section or WP_Error.
*/
public static function add_section( $id, $plugin_id, $args ) {
$new_section = self::create_item( 'section', 'Section', $id, $plugin_id, $args );
if ( is_wp_error( $new_section ) ) {
return $new_section;
}
self::$form_sections[ $id ] = $new_section;
return $new_section;
}
/**
* Adds a tab to the product form.
*
* @param string $id Card id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* @return Tab|WP_Error New section or WP_Error.
*/
public static function add_tab( $id, $plugin_id, $args ) {
$new_tab = self::create_item( 'tab', 'Tab', $id, $plugin_id, $args );
if ( is_wp_error( $new_tab ) ) {
return $new_tab;
}
self::$form_tabs[ $id ] = $new_tab;
return $new_tab;
}
/**
* Returns list of registered fields.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered fields.
*/
public static function get_fields( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'field', 'Field', $sort_by );
}
/**
* Returns list of registered cards.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered cards.
*/
public static function get_subsections( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'subsection', 'Subsection', $sort_by );
}
/**
* Returns list of registered sections.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered sections.
*/
public static function get_sections( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'section', 'Section', $sort_by );
}
/**
* Returns list of registered tabs.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered tabs.
*/
public static function get_tabs( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'tab', 'Tab', $sort_by );
}
/**
* Returns list of registered items.
*
* @param string $type Form component type.
* @return array List of registered items.
*/
private static function get_item_list( $type ) {
$mapping = array(
'field' => self::$form_fields,
'subsection' => self::$form_subsections,
'section' => self::$form_sections,
'tab' => self::$form_tabs,
);
if ( array_key_exists( $type, $mapping ) ) {
return $mapping[ $type ];
}
return array();
}
/**
* Returns list of registered items.
*
* @param string $type Form component type.
* @param class-string $class_name Class of component type.
* @param array $sort_by key and order to sort by.
* @return array list of registered items.
*/
private static function get_items( $type, $class_name, $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
$item_list = self::get_item_list( $type );
$class = 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm\\' . $class_name;
$items = array_values( $item_list );
if ( class_exists( $class ) && method_exists( $class, 'sort' ) ) {
usort(
$items,
function ( $a, $b ) use ( $sort_by, $class ) {
return $class::sort( $a, $b, $sort_by );
}
);
}
return $items;
}
/**
* Creates a new item.
*
* @param string $type Form component type.
* @param class-string $class_name Class of component type.
* @param string $id Item id.
* @param string $plugin_id Plugin id.
* @param array $args additional arguments for item.
* @return Field|Card|Section|Tab|WP_Error New product form item or WP_Error.
*/
private static function create_item( $type, $class_name, $id, $plugin_id, $args ) {
$item_list = self::get_item_list( $type );
$class = 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm\\' . $class_name;
if ( ! class_exists( $class ) ) {
return new WP_Error(
'wc_product_form_' . $type . '_missing_form_class',
sprintf(
/* translators: 1: missing class name. */
esc_html__( '%1$s class does not exist.', 'woocommerce' ),
$class
)
);
}
if ( isset( $item_list[ $id ] ) ) {
return new WP_Error(
'wc_product_form_' . $type . '_duplicate_field_id',
sprintf(
/* translators: 1: Item type 2: Duplicate registered item id. */
esc_html__( 'You have attempted to register a duplicate form %1$s with WooCommerce Form: %2$s', 'woocommerce' ),
$type,
'`' . $id . '`'
)
);
}
$defaults = array(
'order' => 20,
);
$item_arguments = wp_parse_args( $args, $defaults );
try {
return new $class( $id, $plugin_id, $item_arguments );
} catch ( \Exception $e ) {
return new WP_Error(
'wc_product_form_' . $type . '_class_creation',
$e->getMessage()
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More