diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json
index 06efcba2d4a..d18a4367869 100644
--- a/docs/docs-manifest.json
+++ b/docs/docs-manifest.json
@@ -1229,7 +1229,7 @@
"menu_title": "Core critical flows",
"tags": "reference",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/quality-and-best-practices/core-critical-flows.md",
- "hash": "472f5a240abe907fec83a8a9f88af6699f2d994aa7ae87faa1716a087baa66db",
+ "hash": "34109195216ebcb5b23e741391b9f355ba861777a5533d4ef1e341472cb5209e",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/quality-and-best-practices/core-critical-flows.md",
"id": "e561b46694dba223c38b87613ce4907e4e14333a"
},
@@ -1804,5 +1804,5 @@
"categories": []
}
],
- "hash": "3cad7f812ae15abd4936a536cf56db63b0f1b549e26eeb3427fe45989647d58c"
+ "hash": "47dc2a7e213e1e9d83e93a85dafdf8c7b539cc5474b4166eb6398b734d150ff3"
}
\ No newline at end of file
diff --git a/docs/quality-and-best-practices/core-critical-flows.md b/docs/quality-and-best-practices/core-critical-flows.md
index d959b4d54e6..11fb8ec2466 100644
--- a/docs/quality-and-best-practices/core-critical-flows.md
+++ b/docs/quality-and-best-practices/core-critical-flows.md
@@ -147,13 +147,14 @@ These flows will continually evolve as the platform evolves with flows updated,
### Merchant - Settings
| User Type | Flow Area | Flow Name | Test File |
-| --------- | --------- | -------------------------------------- | ---------------------------------------- |
+| --------- | --------- |----------------------------------------|------------------------------------------|
| Merchant | Settings | Update General Settings | merchant/settings-general.spec.js |
| Merchant | Settings | Add Tax Rates | merchant/settings-tax.spec.js |
| Merchant | Settings | Add Shipping Zones | merchant/create-shipping-zones.spec.js |
| Merchant | Settings | Add Shipping Classes | merchant/create-shipping-classes.spec.js |
| Merchant | Settings | Enable local pickup for checkout block | merchant/settings-shipping.spec.js |
| Merchant | Settings | Update payment settings | admin-tasks/payment.spec.js |
+| Merchant | Settings | Handle Product Brands | merchant/create-product-brand.spec.js |
### Merchant - Coupons
diff --git a/plugins/woocommerce/changelog/merge-brands-in-core b/plugins/woocommerce/changelog/merge-brands-in-core
new file mode 100644
index 00000000000..65fd35876a3
--- /dev/null
+++ b/plugins/woocommerce/changelog/merge-brands-in-core
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Introduced Product Brands.
diff --git a/plugins/woocommerce/client/legacy/css/brands-admin.scss b/plugins/woocommerce/client/legacy/css/brands-admin.scss
new file mode 100644
index 00000000000..5f9e47fed78
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/css/brands-admin.scss
@@ -0,0 +1,3 @@
+table.wp-list-table .column-taxonomy-product_brand {
+ width: 10%;
+}
diff --git a/plugins/woocommerce/client/legacy/css/brands.scss b/plugins/woocommerce/client/legacy/css/brands.scss
new file mode 100644
index 00000000000..060d28a0278
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/css/brands.scss
@@ -0,0 +1,173 @@
+/* Brand description on archives */
+.tax-product_brand .brand-description {
+ overflow: hidden;
+ zoom: 1;
+}
+.tax-product_brand .brand-description img.brand-thumbnail {
+ width: 25%;
+ float: right;
+}
+.tax-product_brand .brand-description .text {
+ width: 72%;
+ float: left;
+}
+
+/* Brand description widget */
+.widget_brand_description img {
+ -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
+ -moz-box-sizing: border-box; /* Firefox, other Gecko */
+ box-sizing: border-box; /* Opera/IE 8+ */
+ width: 100%;
+ max-width: none;
+ height: auto;
+ margin: 0 0 1em;
+}
+
+/* Brand thumbnails widget */
+ul.brand-thumbnails {
+ margin-left: 0;
+ margin-bottom: 0;
+ clear: both;
+ list-style: none;
+}
+
+ul.brand-thumbnails:before {
+ clear: both;
+ content: "";
+ display: table;
+}
+
+ul.brand-thumbnails:after {
+ clear: both;
+ content: "";
+ display: table;
+}
+
+ul.brand-thumbnails li {
+ float: left;
+ margin: 0 3.8% 1em 0;
+ padding: 0;
+ position: relative;
+ width: 22.05%; /* 4 columns */
+}
+
+ul.brand-thumbnails.fluid-columns li {
+ width: auto;
+}
+
+ul.brand-thumbnails:not(.fluid-columns) li.first {
+ clear: both;
+}
+
+ul.brand-thumbnails:not(.fluid-columns) li.last {
+ margin-right: 0;
+}
+
+ul.brand-thumbnails.columns-1 li {
+ width: 100%;
+ margin-right: 0;
+}
+
+ul.brand-thumbnails.columns-2 li {
+ width: 48%;
+}
+
+ul.brand-thumbnails.columns-3 li {
+ width: 30.75%;
+}
+
+ul.brand-thumbnails.columns-5 li {
+ width: 16.95%;
+}
+
+ul.brand-thumbnails.columns-6 li {
+ width: 13.5%;
+}
+
+.brand-thumbnails li img {
+ -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
+ -moz-box-sizing: border-box; /* Firefox, other Gecko */
+ box-sizing: border-box; /* Opera/IE 8+ */
+ width: 100%;
+ max-width: none;
+ height: auto;
+ margin: 0;
+}
+
+@media screen and (max-width: 768px) {
+ ul.brand-thumbnails:not(.fluid-columns) li {
+ width: 48% !important;
+ }
+
+ ul.brand-thumbnails:not(.fluid-columns) li.first {
+ clear: none;
+ }
+
+ ul.brand-thumbnails:not(.fluid-columns) li.last {
+ margin-right: 3.8%
+ }
+
+ ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(odd) {
+ clear: both;
+ }
+
+ ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(even) {
+ margin-right: 0;
+ }
+}
+
+/* Brand thumbnails description */
+.brand-thumbnails-description li {
+ text-align: center;
+}
+
+.brand-thumbnails-description li .term-thumbnail img {
+ display: inline;
+}
+
+.brand-thumbnails-description li .term-description {
+ margin-top: 1em;
+ text-align: left;
+}
+
+/* A-Z Shortcode */
+#brands_a_z h3:target {
+ text-decoration: underline;
+}
+ul.brands_index {
+ list-style: none outside;
+ overflow: hidden;
+ zoom: 1;
+}
+ul.brands_index li {
+ float: left;
+ margin: 0 2px 2px 0;
+}
+ul.brands_index li a, ul.brands_index li span {
+ border: 1px solid #ccc;
+ padding: 6px;
+ line-height: 1em;
+ float: left;
+ text-decoration: none;
+}
+ul.brands_index li span {
+ border-color: #eee;
+ color: #ddd;
+}
+ul.brands_index li a:hover {
+ border-width: 2px;
+ padding: 5px;
+ text-decoration: none;
+}
+ul.brands_index li a.active {
+ border-width: 2px;
+ padding: 5px;
+}
+div#brands_a_z a.top {
+ border: 1px solid #ccc;
+ padding: 4px;
+ line-height: 1em;
+ float: right;
+ text-decoration: none;
+ font-size: 0.8em;
+}
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js b/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js
new file mode 100644
index 00000000000..270d5b8dc1c
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js
@@ -0,0 +1,94 @@
+/* global wc_enhanced_select_params */
+/* global wpApiSettings */
+jQuery( function( $ ) {
+
+ function getEnhancedSelectFormatString() {
+ return {
+ 'language': {
+ errorLoading: function() {
+ // Workaround for https://github.com/select2/select2/issues/4355 instead of i18n_ajax_error.
+ return wc_enhanced_select_params.i18n_searching;
+ },
+ inputTooLong: function( args ) {
+ var overChars = args.input.length - args.maximum;
+
+ if ( 1 === overChars ) {
+ return wc_enhanced_select_params.i18n_input_too_long_1;
+ }
+
+ return wc_enhanced_select_params.i18n_input_too_long_n.replace( '%qty%', overChars );
+ },
+ inputTooShort: function( args ) {
+ var remainingChars = args.minimum - args.input.length;
+
+ if ( 1 === remainingChars ) {
+ return wc_enhanced_select_params.i18n_input_too_short_1;
+ }
+
+ return wc_enhanced_select_params.i18n_input_too_short_n.replace( '%qty%', remainingChars );
+ },
+ loadingMore: function() {
+ return wc_enhanced_select_params.i18n_load_more;
+ },
+ maximumSelected: function( args ) {
+ if ( args.maximum === 1 ) {
+ return wc_enhanced_select_params.i18n_selection_too_long_1;
+ }
+
+ return wc_enhanced_select_params.i18n_selection_too_long_n.replace( '%qty%', args.maximum );
+ },
+ noResults: function() {
+ return wc_enhanced_select_params.i18n_no_matches;
+ },
+ searching: function() {
+ return wc_enhanced_select_params.i18n_searching;
+ }
+ }
+ };
+ }
+
+ try {
+ $( document.body )
+ .on( 'wc-enhanced-select-init', function() {
+ // Ajax category search boxes
+ $( ':input.wc-brands-search' ).filter( ':not(.enhanced)' ).each( function() {
+ var select2_args = $.extend( {
+ allowClear : $( this ).data( 'allow_clear' ) ? true : false,
+ placeholder : $( this ).data( 'placeholder' ),
+ minimumInputLength: $( this ).data( 'minimum_input_length' ) ? $( this ).data( 'minimum_input_length' ) : 3,
+ escapeMarkup : function( m ) {
+ return m;
+ },
+ ajax: {
+ url: wpApiSettings.root + 'wc/v3/products/brands',
+ dataType: 'json',
+ delay: 250,
+ headers: {
+ 'X-WP-Nonce': wpApiSettings.nonce
+ },
+ data: function( params ) {
+ return {
+ hide_empty: 1,
+ search: params.term
+ };
+ },
+ processResults: function( data ) {
+ const results = data
+ .map( term => ({ id: term.slug, text: term.name + ' (' + term.count + ')' }) )
+ return {
+ results
+ };
+ },
+ cache: true
+ }
+ }, getEnhancedSelectFormatString() );
+
+ $( this ).selectWoo( select2_args ).addClass( 'enhanced' );
+ });
+ })
+ .trigger( 'wc-enhanced-select-init' );
+ } catch( err ) {
+ // If select2 failed (conflict?) log the error but don't stop other scripts breaking.
+ window.console.log( err );
+ }
+});
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-brands.php b/plugins/woocommerce/includes/admin/class-wc-admin-brands.php
new file mode 100644
index 00000000000..a5f65745625
--- /dev/null
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-brands.php
@@ -0,0 +1,792 @@
+settings_tabs = array(
+ 'brands' => __( 'Brands', 'woocommerce' ),
+ );
+
+ // Hiding setting for future depreciation. Only users who have touched this settings should see it.
+ $setting_value = get_option( 'wc_brands_show_description' );
+ if ( is_string( $setting_value ) ) {
+ // Add the settings fields to each tab.
+ $this->init_form_fields();
+ add_action( 'woocommerce_get_sections_products', array( $this, 'add_settings_tab' ) );
+ add_action( 'woocommerce_get_settings_products', array( $this, 'add_settings_section' ), null, 2 );
+ }
+
+ add_action( 'woocommerce_update_options_catalog', array( $this, 'save_admin_settings' ) );
+
+ /* 2.1 */
+ add_action( 'woocommerce_update_options_products', array( $this, 'save_admin_settings' ) );
+
+ // Add brands filtering to the coupon creation screens.
+ add_action( 'woocommerce_coupon_options_usage_restriction', array( $this, 'add_coupon_brands_fields' ) );
+ add_action( 'woocommerce_coupon_options_save', array( $this, 'save_coupon_brands' ) );
+
+ // Permalinks.
+ add_filter( 'pre_update_option_woocommerce_permalinks', array( $this, 'validate_product_base' ) );
+
+ add_action( 'current_screen', array( $this, 'add_brand_base_setting' ) );
+
+ // CSV Import/Export Support.
+ // https://github.com/woocommerce/woocommerce/wiki/Product-CSV-Importer-&-Exporter
+ // Import.
+ add_filter( 'woocommerce_csv_product_import_mapping_options', array( $this, 'add_column_to_importer_exporter' ), 10 );
+ add_filter( 'woocommerce_csv_product_import_mapping_default_columns', array( $this, 'add_default_column_mapping' ), 10 );
+ add_filter( 'woocommerce_product_import_inserted_product_object', array( $this, 'process_import' ), 10, 2 );
+
+ // Export.
+ add_filter( 'woocommerce_product_export_column_names', array( $this, 'add_column_to_importer_exporter' ), 10 );
+ add_filter( 'woocommerce_product_export_product_default_columns', array( $this, 'add_column_to_importer_exporter' ), 10 );
+ add_filter( 'woocommerce_product_export_product_column_brand_ids', array( $this, 'get_column_value_brand_ids' ), 10, 2 );
+ }
+
+ /**
+ * Add the settings for the new "Brands" subtab.
+ *
+ * @since 9.4.0
+ *
+ * @param array $settings Settings.
+ * @param array $current_section Current section.
+ */
+ public function add_settings_section( $settings, $current_section ) {
+ if ( 'brands' === $current_section ) {
+ $settings = $this->settings;
+ }
+ return $settings;
+ }
+
+ /**
+ * Add a new "Brands" subtab to the "Products" tab.
+ *
+ * @since 9.4.0
+ * @param array $sections Sections.
+ */
+ public function add_settings_tab( $sections ) {
+ $sections = array_merge( $sections, $this->settings_tabs );
+ return $sections;
+ }
+
+ /**
+ * Display coupon filter fields relating to brands.
+ *
+ * @since 9.4.0
+ * @return void
+ */
+ public function add_coupon_brands_fields() {
+ global $post;
+ // Brands.
+ ?>
+
+
+ ID, 'product_brands', true );
+ $categories = get_terms(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'orderby' => 'name',
+ 'hide_empty' => false,
+ )
+ );
+
+ if ( $categories ) {
+ foreach ( $categories as $cat ) {
+ echo 'term_id, $category_ids, true ), true, false ) . '>' . esc_html( $cat->name ) . ' ';
+ }
+ }
+ ?>
+
+
+
+
+ ID, 'exclude_product_brands', true );
+ $categories = get_terms(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'orderby' => 'name',
+ 'hide_empty' => false,
+ )
+ );
+
+ if ( $categories ) {
+ foreach ( $categories as $cat ) {
+ echo 'term_id, $category_ids, true ), true, false ) . '>' . esc_html( $cat->name ) . ' ';
+ }
+ }
+ ?>
+
+ settings = apply_filters(
+ 'woocommerce_brands_settings_fields',
+ array(
+ array(
+ 'name' => __( 'Brands Archives', 'woocommerce' ),
+ 'type' => 'title',
+ 'desc' => '',
+ 'id' => 'brands_archives',
+ ),
+ array(
+ 'name' => __( 'Show description', 'woocommerce' ),
+ 'desc' => __( 'Choose to show the brand description on the archive page. Turn this off if you intend to use the description widget instead. Please note: this is only for themes that do not show the description.', 'woocommerce' ),
+ 'tip' => '',
+ 'id' => 'wc_brands_show_description',
+ 'css' => '',
+ 'std' => 'yes',
+ 'type' => 'checkbox',
+ ),
+ array(
+ 'type' => 'sectionend',
+ 'id' => 'brands_archives',
+ ),
+ )
+ );
+ }
+
+ /**
+ * Enqueue scripts.
+ *
+ * @return void
+ */
+ public function scripts() {
+ $screen = get_current_screen();
+ $version = Constants::get_constant( 'WC_VERSION' );
+ $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
+
+ if ( 'edit-product' === $screen->id ) {
+ wp_register_script(
+ 'wc-brands-enhanced-select',
+ WC()->plugin_url() . '/assets/js/admin/wc-brands-enhanced-select' . $suffix . '.js',
+ array( 'jquery', 'selectWoo', 'wc-enhanced-select', 'wp-api' ),
+ $version,
+ true
+ );
+ wp_localize_script(
+ 'wc-brands-enhanced-select',
+ 'wc_brands_enhanced_select_params',
+ array( 'ajax_url' => get_rest_url() . 'brands/search' )
+ );
+ wp_enqueue_script( 'wc-brands-enhanced-select' );
+ }
+
+ if ( in_array( $screen->id, array( 'edit-product_brand' ), true ) ) {
+ wp_enqueue_media();
+ wp_enqueue_style( 'woocommerce_admin_styles' );
+ }
+ }
+
+ /**
+ * Enqueue styles.
+ *
+ * @return void
+ */
+ public function styles() {
+ $version = Constants::get_constant( 'WC_VERSION' );
+ wp_enqueue_style( 'brands-admin-styles', WC()->plugin_url() . '/assets/css/brands-admin.css', array(), $version );
+ }
+
+ /**
+ * Admin settings function.
+ */
+ public function admin_settings() {
+ woocommerce_admin_fields( $this->settings );
+ }
+
+ /**
+ * Save admin settings function.
+ */
+ public function save_admin_settings() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET['section'] ) && 'brands' === $_GET['section'] ) {
+ woocommerce_update_options( $this->settings );
+ }
+ }
+
+ /**
+ * Category thumbnails.
+ */
+ public function add_thumbnail_field() {
+ global $woocommerce;
+ ?>
+
+ term_id, 'thumbnail_id', true );
+ if ( $thumbnail_id ) {
+ $image = wp_get_attachment_url( $thumbnail_id );
+ }
+ if ( empty( $image ) ) {
+ $image = wc_placeholder_img_src();
+ }
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $brands_column ),
+ array_slice( $columns, -2, null, true )
+ );
+ }
+
+
+ /**
+ * Columns function.
+ *
+ * @param mixed $columns Columns.
+ */
+ public function columns( $columns ) {
+ if ( empty( $columns ) ) {
+ return $columns;
+ }
+
+ $new_columns = array();
+ $new_columns['cb'] = $columns['cb'];
+ $new_columns['thumb'] = __( 'Image', 'woocommerce' );
+ unset( $columns['cb'] );
+ $columns = array_merge( $new_columns, $columns );
+ return $columns;
+ }
+
+ /**
+ * Column function.
+ *
+ * @param mixed $columns Columns.
+ * @param mixed $column Column.
+ * @param mixed $id ID.
+ */
+ public function column( $columns, $column, $id ) {
+ if ( 'thumb' === $column ) {
+ global $woocommerce;
+
+ $image = '';
+ $thumbnail_id = get_term_meta( $id, 'thumbnail_id', true );
+
+ if ( $thumbnail_id ) {
+ $image = wp_get_attachment_url( $thumbnail_id );
+ }
+ if ( empty( $image ) ) {
+ $image = wc_placeholder_img_src();
+ }
+
+ $columns .= ' ';
+
+ }
+ return $columns;
+ }
+
+ /**
+ * Renders either dropdown or a search field for brands depending on the threshold value of
+ * woocommerce_product_brand_filter_threshold filter.
+ */
+ public function render_product_brand_filter() {
+ // phpcs:disable WordPress.Security.NonceVerification
+ $brands_count = (int) wp_count_terms( 'product_brand' );
+ $current_brand_slug = wc_clean( wp_unslash( $_GET['product_brand'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ /**
+ * Filter the brands threshold count.
+ *
+ * @since 9.4.0
+ *
+ * @param int $value Threshold.
+ */
+ if ( $brands_count <= apply_filters( 'woocommerce_product_brand_filter_threshold', 100 ) ) {
+ wc_product_dropdown_categories(
+ array(
+ 'pad_counts' => true,
+ 'show_count' => true,
+ 'orderby' => 'name',
+ 'selected' => $current_brand_slug,
+ 'show_option_none' => __( 'Filter by brand', 'woocommerce' ),
+ 'option_none_value' => '',
+ 'value_field' => 'slug',
+ 'taxonomy' => 'product_brand',
+ 'name' => 'product_brand',
+ 'class' => 'dropdown_product_brand',
+ )
+ );
+ } else {
+ $current_brand = $current_brand_slug ? get_term_by( 'slug', $current_brand_slug, 'product_brand' ) : '';
+ $selected_option = '';
+ if ( $current_brand_slug && $current_brand ) {
+ $selected_option = '' . esc_html( htmlspecialchars( wp_kses_post( $current_brand->name ) ) ) . ' ';
+ }
+ $placeholder = esc_attr__( 'Filter by brand', 'woocommerce' );
+ ?>
+
+
+
+ id ) {
+ return;
+ }
+
+ add_settings_field(
+ 'woocommerce_product_brand_slug',
+ __( 'Product brand base', 'woocommerce' ),
+ array( $this, 'product_brand_slug_input' ),
+ 'permalink',
+ 'optional'
+ );
+
+ $this->save_permalink_settings();
+ }
+
+ /**
+ * Add a slug input box.
+ */
+ public function product_brand_slug_input() {
+ $permalink = get_option( 'woocommerce_brand_permalink', '' );
+ ?>
+
+ 'brand_ids' );
+ return array_merge( $mappings, $new_mapping );
+ }
+
+ /**
+ * Add brands to newly imported product.
+ *
+ * @param WC_Product $product Product being imported.
+ * @param array $data Raw CSV data.
+ */
+ public function process_import( $product, $data ) {
+ if ( empty( $data['brand_ids'] ) ) {
+ return;
+ }
+
+ $brand_ids = array_map( 'intval', $this->parse_brands_field( $data['brand_ids'] ) );
+
+ wp_set_object_terms( $product->get_id(), $brand_ids, 'product_brand' );
+ }
+
+ /**
+ * Parse brands field from a CSV during import.
+ *
+ * Based on WC_Product_CSV_Importer::parse_categories_field()
+ *
+ * @param string $value Field value.
+ * @return array
+ */
+ public function parse_brands_field( $value ) {
+
+ // Based on WC_Product_Importer::explode_values().
+ $values = str_replace( '\\,', '::separator::', explode( ',', $value ) );
+ $row_terms = array();
+ foreach ( $values as $row_value ) {
+ $row_terms[] = trim( str_replace( '::separator::', ',', $row_value ) );
+ }
+
+ $brands = array();
+ foreach ( $row_terms as $row_term ) {
+ $parent = null;
+
+ // WC Core uses '>', but for some reason it's already escaped at this point.
+ $_terms = array_map( 'trim', explode( '>', $row_term ) );
+ $total = count( $_terms );
+
+ foreach ( $_terms as $index => $_term ) {
+ $term = term_exists( $_term, 'product_brand', $parent );
+
+ if ( is_array( $term ) ) {
+ $term_id = $term['term_id'];
+ } else {
+ $term = wp_insert_term( $_term, 'product_brand', array( 'parent' => intval( $parent ) ) );
+
+ if ( is_wp_error( $term ) ) {
+ break; // We cannot continue if the term cannot be inserted.
+ }
+
+ $term_id = $term['term_id'];
+ }
+
+ // Only requires assign the last category.
+ if ( ( 1 + $index ) === $total ) {
+ $brands[] = $term_id;
+ } else {
+ // Store parent to be able to insert or query brands based in parent ID.
+ $parent = $term_id;
+ }
+ }
+ }
+
+ return $brands;
+ }
+
+ /**
+ * Get brands column value for csv export.
+ *
+ * @param string $value What will be exported.
+ * @param WC_Product $product Product being exported.
+ * @return string Brands separated by commas and child brands as "parent > child".
+ */
+ public function get_column_value_brand_ids( $value, $product ) {
+ $brand_ids = wp_parse_id_list( wp_get_post_terms( $product->get_id(), 'product_brand', array( 'fields' => 'ids' ) ) );
+
+ if ( ! count( $brand_ids ) ) {
+ return '';
+ }
+
+ // Based on WC_CSV_Exporter::format_term_ids().
+ $formatted_brands = array();
+ foreach ( $brand_ids as $brand_id ) {
+ $formatted_term = array();
+ $ancestor_ids = array_reverse( get_ancestors( $brand_id, 'product_brand' ) );
+
+ foreach ( $ancestor_ids as $ancestor_id ) {
+ $term = get_term( $ancestor_id, 'product_brand' );
+ if ( $term && ! is_wp_error( $term ) ) {
+ $formatted_term[] = $term->name;
+ }
+ }
+
+ $term = get_term( $brand_id, 'product_brand' );
+
+ if ( $term && ! is_wp_error( $term ) ) {
+ $formatted_term[] = $term->name;
+ }
+
+ $formatted_brands[] = implode( ' > ', $formatted_term );
+ }
+
+ // Based on WC_CSV_Exporter::implode_values().
+ $values_to_implode = array();
+ foreach ( $formatted_brands as $brand ) {
+ $brand = (string) is_scalar( $brand ) ? $brand : '';
+ $values_to_implode[] = str_replace( ',', '\\,', $brand );
+ }
+
+ return implode( ', ', $values_to_implode );
+ }
+}
+
+$GLOBALS['WC_Brands_Admin'] = new WC_Brands_Admin();
diff --git a/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php b/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php
new file mode 100644
index 00000000000..19844bc9d66
--- /dev/null
+++ b/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php
@@ -0,0 +1,369 @@
+ 'block-templates',
+ 'DEPRECATED_TEMPLATE_PARTS' => 'block-template-parts',
+ 'TEMPLATES' => 'templates',
+ 'TEMPLATE_PARTS' => 'parts',
+ );
+
+ /**
+ * WooCommerce plugin slug
+ *
+ * This is used to save templates to the DB which are stored against this value in the wp_terms table.
+ *
+ * @var string
+ */
+ protected const PLUGIN_SLUG = 'woocommerce/woocommerce';
+
+ /**
+ * Returns an array containing the references of
+ * the passed blocks and their inner blocks.
+ *
+ * @param array $blocks array of blocks.
+ *
+ * @return array block references to the passed blocks and their inner blocks.
+ */
+ public static function gutenberg_flatten_blocks( &$blocks ) {
+ $all_blocks = array();
+ $queue = array();
+ foreach ( $blocks as &$block ) {
+ $queue[] = &$block;
+ }
+ $queue_count = count( $queue );
+
+ while ( $queue_count > 0 ) {
+ $block = &$queue[0];
+ array_shift( $queue );
+ $all_blocks[] = &$block;
+
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ foreach ( $block['innerBlocks'] as &$inner_block ) {
+ $queue[] = &$inner_block;
+ }
+ }
+
+ $queue_count = count( $queue );
+ }
+
+ return $all_blocks;
+ }
+
+ /**
+ * Parses wp_template content and injects the current theme's
+ * stylesheet as a theme attribute into each wp_template_part
+ *
+ * @param string $template_content serialized wp_template content.
+ *
+ * @return string Updated wp_template content.
+ */
+ public static function gutenberg_inject_theme_attribute_in_content( $template_content ) {
+ $has_updated_content = false;
+ $new_content = '';
+ $template_blocks = parse_blocks( $template_content );
+
+ $blocks = self::gutenberg_flatten_blocks( $template_blocks );
+ foreach ( $blocks as &$block ) {
+ if (
+ 'core/template-part' === $block['blockName'] &&
+ ! isset( $block['attrs']['theme'] )
+ ) {
+ $block['attrs']['theme'] = wp_get_theme()->get_stylesheet();
+ $has_updated_content = true;
+ }
+ }
+
+ if ( $has_updated_content ) {
+ foreach ( $template_blocks as &$block ) {
+ $new_content .= serialize_block( $block );
+ }
+
+ return $new_content;
+ }
+
+ return $template_content;
+ }
+
+ /**
+ * Build a unified template object based a post Object.
+ *
+ * @param \WP_Post $post Template post.
+ *
+ * @return \WP_Block_Template|\WP_Error Template.
+ */
+ public static function gutenberg_build_template_result_from_post( $post ) {
+ $terms = get_the_terms( $post, 'wp_theme' );
+
+ if ( is_wp_error( $terms ) ) {
+ return $terms;
+ }
+
+ if ( ! $terms ) {
+ return new \WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'woocommerce' ) );
+ }
+
+ $theme = $terms[0]->name;
+ $has_theme_file = true;
+
+ $template = new \WP_Block_Template();
+ $template->wp_id = $post->ID;
+ $template->id = $theme . '//' . $post->post_name;
+ $template->theme = $theme;
+ $template->content = $post->post_content;
+ $template->slug = $post->post_name;
+ $template->source = 'custom';
+ $template->type = $post->post_type;
+ $template->description = $post->post_excerpt;
+ $template->title = $post->post_title;
+ $template->status = $post->post_status;
+ $template->has_theme_file = $has_theme_file;
+ $template->is_custom = false;
+ $template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
+
+ if ( 'wp_template_part' === $post->post_type ) {
+ $type_terms = get_the_terms( $post, 'wp_template_part_area' );
+ if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) {
+ $template->area = $type_terms[0]->name;
+ }
+ }
+
+ // We are checking 'woocommerce' to maintain legacy templates which are saved to the DB,
+ // prior to updating to use the correct slug.
+ // More information found here: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5423.
+ if ( self::PLUGIN_SLUG === $theme || 'woocommerce' === strtolower( $theme ) ) {
+ $template->origin = 'plugin';
+ }
+
+ return $template;
+ }
+
+ /**
+ * Build a unified template object based on a theme file.
+ *
+ * @param array|object $template_file Theme file.
+ * @param string $template_type wp_template or wp_template_part.
+ *
+ * @return \WP_Block_Template Template.
+ */
+ public static function gutenberg_build_template_result_from_file( $template_file, $template_type ) {
+ $template_file = (object) $template_file;
+
+ // If the theme has an archive-products.html template but does not have product taxonomy templates
+ // then we will load in the archive-product.html template from the theme to use for product taxonomies on the frontend.
+ $template_is_from_theme = 'theme' === $template_file->source;
+ $theme_name = wp_get_theme()->get( 'TextDomain' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $template_content = file_get_contents( $template_file->path );
+ $template = new \WP_Block_Template();
+ $template->id = $template_is_from_theme ? $theme_name . '//' . $template_file->slug : self::PLUGIN_SLUG . '//' . $template_file->slug;
+ $template->theme = $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG;
+ $template->content = self::gutenberg_inject_theme_attribute_in_content( $template_content );
+ // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
+ $template->source = $template_file->source ? $template_file->source : 'plugin';
+ $template->slug = $template_file->slug;
+ $template->type = $template_type;
+ $template->title = ! empty( $template_file->title ) ? $template_file->title : self::convert_slug_to_title( $template_file->slug );
+ $template->status = 'publish';
+ $template->has_theme_file = true;
+ $template->origin = $template_file->source;
+ $template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
+ $template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
+ $template->area = 'uncategorized';
+ return $template;
+ }
+
+ /**
+ * Build a new template object so that we can make Woo Blocks default templates available in the current theme should they not have any.
+ *
+ * @param string $template_file Block template file path.
+ * @param string $template_type wp_template or wp_template_part.
+ * @param string $template_slug Block template slug e.g. single-product.
+ * @param bool $template_is_from_theme If the block template file is being loaded from the current theme instead of Woo Blocks.
+ *
+ * @return object Block template object.
+ */
+ public static function create_new_block_template_object( $template_file, $template_type, $template_slug, $template_is_from_theme = false ) {
+ $theme_name = wp_get_theme()->get( 'TextDomain' );
+
+ $new_template_item = array(
+ 'slug' => $template_slug,
+ 'id' => $template_is_from_theme ? $theme_name . '//' . $template_slug : self::PLUGIN_SLUG . '//' . $template_slug,
+ 'path' => $template_file,
+ 'type' => $template_type,
+ 'theme' => $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG,
+ // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
+ 'source' => $template_is_from_theme ? 'theme' : 'plugin',
+ 'title' => self::convert_slug_to_title( $template_slug ),
+ 'description' => '',
+ 'post_types' => array(), // Don't appear in any Edit Post template selector dropdown.
+ );
+
+ return (object) $new_template_item;
+ }
+
+
+ /**
+ * Converts template slugs into readable titles.
+ *
+ * @param string $template_slug The templates slug (e.g. single-product).
+ * @return string Human friendly title converted from the slug.
+ */
+ public static function convert_slug_to_title( $template_slug ) {
+ switch ( $template_slug ) {
+ case 'single-product':
+ return __( 'Single Product', 'woocommerce' );
+ case 'archive-product':
+ return __( 'Product Archive', 'woocommerce' );
+ case 'taxonomy-product_cat':
+ return __( 'Product Category', 'woocommerce' );
+ case 'taxonomy-product_tag':
+ return __( 'Product Tag', 'woocommerce' );
+ default:
+ // Replace all hyphens and underscores with spaces.
+ return ucwords( preg_replace( '/[\-_]/', ' ', $template_slug ) );
+ }
+ }
+
+
+ /**
+ * Gets the first matching template part within themes directories
+ *
+ * Since [Gutenberg 12.1.0](https://github.com/WordPress/gutenberg/releases/tag/v12.1.0), the conventions for
+ * block templates and parts directory has changed from `block-templates` and `block-templates-parts`
+ * to `templates` and `parts` respectively.
+ *
+ * This function traverses all possible combinations of directory paths where a template or part
+ * could be located and returns the first one which is readable, prioritizing the new convention
+ * over the deprecated one, but maintaining that one for backwards compatibility.
+ *
+ * @param string $template_slug The slug of the template (i.e. without the file extension).
+ * @param string $template_type Either `wp_template` or `wp_template_part`.
+ *
+ * @return string|null The matched path or `null` if no match was found.
+ */
+ public static function get_theme_template_path( $template_slug, $template_type = 'wp_template' ) {
+ $template_filename = $template_slug . '.html';
+ $possible_templates_dir = 'wp_template' === $template_type ? array(
+ self::DIRECTORY_NAMES['TEMPLATES'],
+ self::DIRECTORY_NAMES['DEPRECATED_TEMPLATES'],
+ ) : array(
+ self::DIRECTORY_NAMES['TEMPLATE_PARTS'],
+ self::DIRECTORY_NAMES['DEPRECATED_TEMPLATE_PARTS'],
+ );
+
+ // Combine the possible root directory names with either the template directory
+ // or the stylesheet directory for child themes.
+ $possible_paths = array_reduce(
+ $possible_templates_dir,
+ function ( $carry, $item ) use ( $template_filename ) {
+ $filepath = DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . $template_filename;
+
+ $carry[] = get_template_directory() . $filepath;
+ $carry[] = get_stylesheet_directory() . $filepath;
+
+ return $carry;
+ },
+ array()
+ );
+
+ // Return the first matching.
+ foreach ( $possible_paths as $path ) {
+ if ( is_readable( $path ) ) {
+ return $path;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if the theme has a template. So we know if to load our own in or not.
+ *
+ * @param string $template_name name of the template file without .html extension e.g. 'single-product'.
+ * @return boolean
+ */
+ public static function theme_has_template( $template_name ) {
+ return (bool) self::get_theme_template_path( $template_name, 'wp_template' );
+ }
+
+ /**
+ * Check if the theme has a template. So we know if to load our own in or not.
+ *
+ * @param string $template_name name of the template file without .html extension e.g. 'single-product'.
+ * @return boolean
+ */
+ public static function theme_has_template_part( $template_name ) {
+ return (bool) self::get_theme_template_path( $template_name, 'wp_template_part' );
+ }
+
+ /**
+ * Checks to see if they are using a compatible version of WP, or if not they have a compatible version of the Gutenberg plugin installed.
+ *
+ * @return boolean
+ */
+ public static function supports_block_templates() {
+ if (
+ ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) &&
+ ( ! function_exists( 'gutenberg_supports_block_templates' ) || ! gutenberg_supports_block_templates() )
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns whether the blockified templates should be used or not.
+ *
+ * First, we need to make sure WordPress version is higher than 6.1 (lowest that supports Products block).
+ * Then, if the option is not stored on the db, we need to check if the current theme is a block one or not.
+ *
+ * @return boolean
+ */
+ public static function should_use_blockified_product_grid_templates() {
+ $minimum_wp_version = '6.1';
+
+ if ( version_compare( $GLOBALS['wp_version'], $minimum_wp_version, '<' ) ) {
+ return false;
+ }
+
+ $use_blockified_templates = wc_string_to_bool( get_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE ) );
+
+ if ( false === $use_blockified_templates ) {
+ return function_exists( 'wc_current_theme_is_fse_theme' ) && wc_current_theme_is_fse_theme();
+ }
+
+ return $use_blockified_templates;
+ }
+}
diff --git a/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php b/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php
new file mode 100644
index 00000000000..efba2807519
--- /dev/null
+++ b/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php
@@ -0,0 +1,156 @@
+ 'taxonomy-product_brand',
+ 'post_type' => 'wp_template',
+ 'post_status' => 'publish',
+ 'posts_per_page' => 1,
+ )
+ );
+
+ if ( count( $posts ) ) {
+ return $posts[0];
+ }
+
+ return null;
+ }
+
+ /**
+ * Fixes a bug regarding taxonomies and FSE.
+ * Without this, the system will always load archive-product.php version instead of taxonomy_product_brand.html
+ * it will show a deprecation error if that happens.
+ *
+ * Triggered by woocommerce_has_block_template filter
+ *
+ * @param bool $has_template True if the template is available.
+ * @param string $template_name The name of the template.
+ *
+ * @return bool True if the system is checking archive-product
+ */
+ public function has_block_template( $has_template, $template_name ) {
+ if ( 'archive-product' === $template_name || 'taxonomy-product_brand' === $template_name ) {
+ $has_template = true;
+ }
+
+ return $has_template;
+ }
+
+ /**
+ * Get the block template for Taxonomy Product Brand. First it attempts to load the last version from DB
+ * Otherwise it loads the file based template.
+ *
+ * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part.
+ *
+ * @return WP_Block_Template The taxonomy-product_brand template.
+ */
+ private function get_product_brands_template( $template_type ) {
+ $template_db = $this->get_product_brand_template_db();
+
+ if ( $template_db ) {
+ return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_post( $template_db );
+ }
+
+ $template_path = BlockTemplateUtilsDuplicated::should_use_blockified_product_grid_templates()
+ ? WC()->plugin_path() . '/templates/templates/blockified/taxonomy-product_brand.html'
+ : WC()->plugin_path() . '/templates/templates/taxonomy-product_brand.html';
+
+ $template_file = BlockTemplateUtilsDuplicated::create_new_block_template_object( $template_path, $template_type, 'taxonomy-product_brand', false );
+
+ return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_file( $template_file, $template_type );
+ }
+
+ /**
+ * Function to check if a template name is woocommerce/taxonomy-product_brand
+ *
+ * Notice depending on the version of WooCommerce this could be:
+ *
+ * woocommerce//taxonomy-product_brand
+ * woocommerce/woocommerce//taxonomy-product_brand
+ *
+ * @param String $id The string to check if contains the template name.
+ *
+ * @return bool True if the template is woocommerce/taxonomy-product_brand
+ */
+ private function is_taxonomy_product_brand_template( $id ) {
+ return strpos( $id, 'woocommerce//taxonomy-product_brand' ) !== false;
+ }
+
+ /**
+ * Get the block template for Taxonomy Product Brand if requested.
+ * Triggered by get_block_file_template action
+ *
+ * @param WP_Block_Template|null $block_template The current Block Template loaded, if any.
+ * @param string $id The template id normally in the format theme-slug//template-slug.
+ * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part.
+ *
+ * @return WP_Block_Template|null The taxonomy-product_brand template.
+ */
+ public function get_block_file_template( $block_template, $id, $template_type ) {
+ if ( $this->is_taxonomy_product_brand_template( $id ) && is_null( $block_template ) ) {
+ $block_template = $this->get_product_brands_template( $template_type );
+ }
+
+ return $block_template;
+ }
+
+ /**
+ * Add the Block template in the template query results needed by FSE
+ * Triggered by get_block_templates action
+ *
+ * @param array $query_result The list of templates to render in the query.
+ * @param array $query The current query parameters.
+ * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part.
+ *
+ * @return WP_Block_Template[] Array of the matched Block Templates to render.
+ */
+ public function get_block_templates( $query_result, $query, $template_type ) {
+ // We don't want to run this if we are looking for template-parts. Like the header.
+ if ( 'wp_template' !== $template_type ) {
+ return $query_result;
+ }
+
+ $post_id = isset( $_REQUEST['postId'] ) ? wc_clean( wp_unslash( $_REQUEST['postId'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $slugs = $query['slug__in'] ?? array();
+
+ // Only add the template if asking for Product Brands.
+ if (
+ in_array( 'taxonomy-product_brand', $slugs, true ) ||
+ ( ! $post_id && ! count( $slugs ) ) ||
+ ( ! count( $slugs ) && $this->is_taxonomy_product_brand_template( $post_id ) )
+ ) {
+ $query_result[] = $this->get_product_brands_template( $template_type );
+ }
+
+ return $query_result;
+ }
+}
+
+new WC_Brands_Block_Templates();
diff --git a/plugins/woocommerce/includes/class-wc-autoloader.php b/plugins/woocommerce/includes/class-wc-autoloader.php
index 6f3b3f51be1..3c9c9570eee 100644
--- a/plugins/woocommerce/includes/class-wc-autoloader.php
+++ b/plugins/woocommerce/includes/class-wc-autoloader.php
@@ -77,6 +77,11 @@ class WC_Autoloader {
return;
}
+ // If the class is already loaded from a merged package, prevent autoloader from loading it as well.
+ if ( \Automattic\WooCommerce\Packages::should_load_class( $class ) ) {
+ return;
+ }
+
$file = $this->get_file_name_from_class( $class );
$path = '';
diff --git a/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php b/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php
new file mode 100644
index 00000000000..5b68df7e4df
--- /dev/null
+++ b/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php
@@ -0,0 +1,68 @@
+get_id();
+
+ // Check if the brand settings are already set for this coupon.
+ if ( isset( self::$brand_settings[ $coupon_id ] ) ) {
+ return;
+ }
+
+ $included_brands = get_post_meta( $coupon_id, 'product_brands', true );
+ $included_brands = ! empty( $included_brands ) ? $included_brands : array();
+
+ $excluded_brands = get_post_meta( $coupon_id, 'exclude_product_brands', true );
+ $excluded_brands = ! empty( $excluded_brands ) ? $excluded_brands : array();
+
+ // Store these settings in the static array.
+ self::$brand_settings[ $coupon_id ] = array(
+ 'included_brands' => $included_brands,
+ 'excluded_brands' => $excluded_brands,
+ );
+ }
+
+ /**
+ * Get brand settings for a coupon.
+ *
+ * @param WC_Coupon $coupon Coupon object.
+ * @return array Brand settings (included and excluded brands).
+ */
+ public static function get_brand_settings_on_coupon( $coupon ) {
+ $coupon_id = $coupon->get_id();
+
+ if ( isset( self::$brand_settings[ $coupon_id ] ) ) {
+ return self::$brand_settings[ $coupon_id ];
+ }
+
+ // Default return value if no settings are found.
+ return array(
+ 'included_brands' => array(),
+ 'excluded_brands' => array(),
+ );
+ }
+}
diff --git a/plugins/woocommerce/includes/class-wc-brands-coupons.php b/plugins/woocommerce/includes/class-wc-brands-coupons.php
new file mode 100644
index 00000000000..065ec3e0de7
--- /dev/null
+++ b/plugins/woocommerce/includes/class-wc-brands-coupons.php
@@ -0,0 +1,189 @@
+set_brand_settings_on_coupon( $coupon );
+
+ // Only check if coupon has brand restrictions on it.
+ $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon );
+
+ $brand_restrictions = ! empty( $brand_coupon_settings['included_brands'] ) || ! empty( $brand_coupon_settings['excluded_brands'] );
+ if ( ! $brand_restrictions ) {
+ return $valid;
+ }
+
+ $included_brands_match = false;
+ $excluded_brands_matches = 0;
+
+ $items = $discounts->get_items();
+
+ foreach ( $items as $item ) {
+ $product_brands = $this->get_product_brands( $this->get_product_id( $item->product ) );
+
+ if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) {
+ $included_brands_match = true;
+ }
+
+ if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) {
+ ++$excluded_brands_matches;
+ }
+ }
+
+ // 1) Coupon has a brand requirement but no products in the cart have the brand.
+ if ( ! $included_brands_match && ! empty( $brand_coupon_settings['included_brands'] ) ) {
+ throw new Exception( WC_Coupon::E_WC_COUPON_NOT_APPLICABLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ // 2) All products in the cart match brand exclusion rule.
+ if ( count( $items ) === $excluded_brands_matches ) {
+ throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ // 3) For a cart discount, there is at least one product in cart that matches exclusion rule.
+ if ( $coupon->is_type( 'fixed_cart' ) && $excluded_brands_matches > 0 ) {
+ throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Check if a coupon is valid for a product.
+ *
+ * This allows percentage and product discounts to apply to only
+ * the correct products in the cart.
+ *
+ * @param bool $valid Whether the product should get the coupon's discounts.
+ * @param WC_Product $product WC Product Object.
+ * @param WC_Coupon $coupon Coupon object.
+ * @return bool $valid
+ */
+ public function is_valid_for_product( $valid, $product, $coupon ) {
+
+ if ( ! is_a( $product, 'WC_Product' ) ) {
+ return $valid;
+ }
+ $this->set_brand_settings_on_coupon( $coupon );
+
+ $product_id = $this->get_product_id( $product );
+ $product_brands = $this->get_product_brands( $product_id );
+
+ // Check if coupon has a brand requirement and if this product has that brand attached.
+ $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon );
+ if ( ! empty( $brand_coupon_settings['included_brands'] ) && empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) {
+ return false;
+ }
+
+ // Check if coupon has a brand exclusion and if this product has that brand attached.
+ if ( ! empty( $brand_coupon_settings['excluded_brands'] ) && ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) {
+ return false;
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Display a custom error message when a cart discount coupon does not validate
+ * because an excluded brand was found in the cart.
+ *
+ * @param string $err The error message.
+ * @param string $err_code The error code.
+ * @return string
+ */
+ public function brand_exclusion_error( $err, $err_code ) {
+ if ( self::E_WC_COUPON_EXCLUDED_BRANDS !== $err_code ) {
+ return $err;
+ }
+
+ return __( 'Sorry, this coupon is not applicable to the brands of selected products.', 'woocommerce' );
+ }
+
+ /**
+ * Get a list of brands that are assigned to a specific product
+ *
+ * @param int $product_id Product id.
+ * @return array brands
+ */
+ private function get_product_brands( $product_id ) {
+ return wp_get_post_terms( $product_id, 'product_brand', array( 'fields' => 'ids' ) );
+ }
+
+ /**
+ * Set brand settings as properties on coupon object. These properties are
+ * lists of included product brand IDs and list of excluded brand IDs.
+ *
+ * @param WC_Coupon $coupon Coupon object.
+ *
+ * @return void
+ */
+ private function set_brand_settings_on_coupon( $coupon ) {
+ $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon );
+
+ if ( ! empty( $brand_coupon_settings['included_brands'] ) && ! empty( $brand_coupon_settings['excluded_brands'] ) ) {
+ return;
+ }
+
+ $included_brands = get_post_meta( $coupon->get_id(), 'product_brands', true );
+ if ( empty( $included_brands ) ) {
+ $included_brands = array();
+ }
+
+ $excluded_brands = get_post_meta( $coupon->get_id(), 'exclude_product_brands', true );
+ if ( empty( $excluded_brands ) ) {
+ $excluded_brands = array();
+ }
+
+ // Store these for later to avoid multiple look-ups.
+ WC_Brands_Brand_Settings_Manager::set_brand_settings_on_coupon( $coupon );
+ }
+
+ /**
+ * Returns the product (or variant) ID.
+ *
+ * @param WC_Product $product WC Product Object.
+ * @return int Product ID
+ */
+ private function get_product_id( $product ) {
+ return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
+ }
+}
+
+new WC_Brands_Coupons();
diff --git a/plugins/woocommerce/includes/class-wc-brands.php b/plugins/woocommerce/includes/class-wc-brands.php
new file mode 100644
index 00000000000..71e1fa71299
--- /dev/null
+++ b/plugins/woocommerce/includes/class-wc-brands.php
@@ -0,0 +1,1070 @@
+template_url = apply_filters( 'woocommerce_template_url', 'woocommerce/' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ add_action( 'plugins_loaded', array( $this, 'register_hooks' ), 2 );
+
+ $this->register_shortcodes();
+ }
+
+ /**
+ * Register our hooks
+ */
+ public function register_hooks() {
+ add_action( 'woocommerce_register_taxonomy', array( __CLASS__, 'init_taxonomy' ) );
+ add_action( 'widgets_init', array( $this, 'init_widgets' ) );
+
+ if ( ! wc_current_theme_is_fse_theme() ) {
+ add_filter( 'template_include', array( $this, 'template_loader' ) );
+ }
+
+ add_action( 'wp_enqueue_scripts', array( $this, 'styles' ) );
+ add_action( 'wp', array( $this, 'body_class' ) );
+
+ add_action( 'woocommerce_product_meta_end', array( $this, 'show_brand' ) );
+ add_filter( 'woocommerce_structured_data_product', array( $this, 'add_structured_data' ), 20 );
+
+ // duplicate product brands.
+ add_action( 'woocommerce_product_duplicate_before_save', array( $this, 'duplicate_store_temporary_brands' ), 10, 2 );
+ add_action( 'woocommerce_new_product', array( $this, 'duplicate_add_product_brand_terms' ) );
+ add_action( 'woocommerce_new_product', array( $this, 'invalidate_wc_layered_nav_counts_cache' ), 10, 0 );
+ add_action( 'woocommerce_update_product', array( $this, 'invalidate_wc_layered_nav_counts_cache' ), 10, 0 );
+ add_action( 'transition_post_status', array( $this, 'reset_layered_nav_counts_on_status_change' ), 10, 3 );
+
+ add_filter( 'post_type_link', array( $this, 'post_type_link' ), 11, 2 );
+
+ if ( 'yes' === get_option( 'wc_brands_show_description' ) ) {
+ add_action( 'woocommerce_archive_description', array( $this, 'brand_description' ) );
+ }
+
+ add_filter( 'woocommerce_product_query_tax_query', array( $this, 'update_product_query_tax_query' ), 10, 1 );
+
+ // REST API.
+ add_action( 'rest_api_init', array( $this, 'rest_api_register_routes' ) );
+ add_action( 'woocommerce_rest_insert_product', array( $this, 'rest_api_maybe_set_brands' ), 10, 2 );
+ add_filter( 'woocommerce_rest_prepare_product', array( $this, 'rest_api_prepare_brands_to_product' ), 10, 2 ); // WC 2.6.x.
+ add_filter( 'woocommerce_rest_prepare_product_object', array( $this, 'rest_api_prepare_brands_to_product' ), 10, 2 ); // WC 3.x.
+ add_action( 'woocommerce_rest_insert_product', array( $this, 'rest_api_add_brands_to_product' ), 10, 3 ); // WC 2.6.x.
+ add_action( 'woocommerce_rest_insert_product_object', array( $this, 'rest_api_add_brands_to_product' ), 10, 3 ); // WC 3.x.
+ add_filter( 'woocommerce_rest_product_object_query', array( $this, 'rest_api_filter_products_by_brand' ), 10, 2 );
+ add_filter( 'rest_product_collection_params', array( $this, 'rest_api_product_collection_params' ), 10, 2 );
+
+ // Layered nav widget compatibility.
+ add_filter( 'woocommerce_layered_nav_term_html', array( $this, 'woocommerce_brands_update_layered_nav_link' ), 10, 4 );
+
+ // Filter the list of taxonomies overridden for the original term count.
+ add_filter( 'woocommerce_change_term_counts', array( $this, 'add_brands_to_terms' ) );
+ add_action( 'woocommerce_product_set_stock_status', array( $this, 'recount_after_stock_change' ) );
+ add_action( 'woocommerce_update_options_products_inventory', array( $this, 'recount_all_brands' ) );
+
+ // Product Editor compatibility.
+ add_action( 'woocommerce_layout_template_after_instantiation', array( $this, 'wc_brands_on_block_template_register' ), 10, 3 );
+ }
+
+ /**
+ * Add product_brand to the taxonomies overridden for the original term count.
+ *
+ * @param array $taxonomies List of taxonomies.
+ *
+ * @return array
+ */
+ public function add_brands_to_terms( $taxonomies ) {
+ $taxonomies[] = 'product_brand';
+ return $taxonomies;
+ }
+
+ /**
+ * Recount the brands after the stock amount changes.
+ *
+ * @param int $product_id Product ID.
+ */
+ public function recount_after_stock_change( $product_id ) {
+ if ( 'yes' !== get_option( 'woocommerce_hide_out_of_stock_items' ) || empty( $product_id ) ) {
+ return;
+ }
+
+ $product_terms = get_the_terms( $product_id, 'product_brand' );
+
+ if ( $product_terms ) {
+ $product_brands = array();
+
+ foreach ( $product_terms as $term ) {
+ $product_brands[ $term->term_id ] = $term->parent;
+ }
+
+ _wc_term_recount( $product_brands, get_taxonomy( 'product_brand' ), false, false );
+ }
+ }
+
+ /**
+ * Recount all brands.
+ */
+ public function recount_all_brands() {
+ $product_brands = get_terms(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'hide_empty' => false,
+ 'fields' => 'id=>parent',
+ )
+ );
+ _wc_term_recount( $product_brands, get_taxonomy( 'product_brand' ), true, false );
+ }
+
+ /**
+ * Update the main product fetch query to filter by selected brands.
+ *
+ * @param array $tax_query array of current taxonomy filters.
+ *
+ * @return array
+ */
+ public function update_product_query_tax_query( array $tax_query ) {
+ if ( isset( $_GET['filter_product_brand'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $brands_filter = array_filter( array_map( 'absint', explode( ',', $filter_product_brand ) ) );
+
+ if ( $brands_filter ) {
+ $tax_query[] = array(
+ 'taxonomy' => 'product_brand',
+ 'terms' => $brands_filter,
+ 'operator' => 'IN',
+ );
+ }
+ }
+
+ return $tax_query;
+ }
+
+ /**
+ * Filter to allow product_brand in the permalinks for products.
+ *
+ * @param string $permalink The existing permalink URL.
+ * @param WP_Post $post The post.
+ * @return string
+ */
+ public function post_type_link( $permalink, $post ) {
+ // Abort if post is not a product.
+ if ( 'product' !== $post->post_type ) {
+ return $permalink;
+ }
+
+ // Abort early if the placeholder rewrite tag isn't in the generated URL.
+ if ( false === strpos( $permalink, '%' ) ) {
+ return $permalink;
+ }
+
+ // Get the custom taxonomy terms in use by this post.
+ $terms = get_the_terms( $post->ID, 'product_brand' );
+
+ if ( empty( $terms ) ) {
+ // If no terms are assigned to this post, use a string instead (can't leave the placeholder there).
+ $product_brand = _x( 'uncategorized', 'slug', 'woocommerce' );
+ } else {
+ // Replace the placeholder rewrite tag with the first term's slug.
+ $first_term = array_shift( $terms );
+ $product_brand = $first_term->slug;
+ }
+
+ $find = array(
+ '%product_brand%',
+ );
+
+ $replace = array(
+ $product_brand,
+ );
+
+ $replace = array_map( 'sanitize_title', $replace );
+
+ $permalink = str_replace( $find, $replace, $permalink );
+
+ return $permalink;
+ }
+
+ /**
+ * Adds filter for introducing CSS classes.
+ */
+ public function body_class() {
+ if ( is_tax( 'product_brand' ) ) {
+ add_filter( 'body_class', array( $this, 'add_body_class' ) );
+ }
+ }
+
+ /**
+ * Adds classes to brand taxonomy pages.
+ *
+ * @param array $classes Classes array.
+ */
+ public function add_body_class( $classes ) {
+ $classes[] = 'woocommerce';
+ $classes[] = 'woocommerce-page';
+ return $classes;
+ }
+
+ /**
+ * Enqueues styles.
+ */
+ public function styles() {
+ $version = Constants::get_constant( 'WC_VERSION' );
+ wp_enqueue_style( 'brands-styles', WC()->plugin_url() . '/assets/css/brands.css', array(), $version );
+ }
+
+ /**
+ * Initializes brand taxonomy.
+ */
+ public static function init_taxonomy() {
+ $shop_page_id = wc_get_page_id( 'shop' );
+
+ $base_slug = $shop_page_id > 0 && get_page( $shop_page_id ) ? get_page_uri( $shop_page_id ) : 'shop';
+ $category_base = get_option( 'woocommerce_prepend_shop_page_to_urls' ) === 'yes' ? trailingslashit( $base_slug ) : '';
+
+ $slug = $category_base . __( 'brand', 'woocommerce' );
+ if ( '' === $category_base ) {
+ $slug = get_option( 'woocommerce_brand_permalink', '' );
+ }
+
+ // Can't provide transatable string as get_option default.
+ if ( '' === $slug ) {
+ $slug = __( 'brand', 'woocommerce' );
+ }
+
+ register_taxonomy(
+ 'product_brand',
+ array( 'product' ),
+ /**
+ * Filter the brand taxonomy.
+ *
+ * @since 9.4.0
+ *
+ * @param array $args Args.
+ */
+ apply_filters(
+ 'register_taxonomy_product_brand',
+ array(
+ 'hierarchical' => true,
+ 'update_count_callback' => '_update_post_term_count',
+ 'label' => __( 'Brands', 'woocommerce' ),
+ 'labels' => array(
+ 'name' => __( 'Brands', 'woocommerce' ),
+ 'singular_name' => __( 'Brand', 'woocommerce' ),
+ 'search_items' => __( 'Search Brands', 'woocommerce' ),
+ 'all_items' => __( 'All Brands', 'woocommerce' ),
+ 'parent_item' => __( 'Parent Brand', 'woocommerce' ),
+ 'parent_item_colon' => __( 'Parent Brand:', 'woocommerce' ),
+ 'edit_item' => __( 'Edit Brand', 'woocommerce' ),
+ 'update_item' => __( 'Update Brand', 'woocommerce' ),
+ 'add_new_item' => __( 'Add New Brand', 'woocommerce' ),
+ 'new_item_name' => __( 'New Brand Name', 'woocommerce' ),
+ 'not_found' => __( 'No Brands Found', 'woocommerce' ),
+ 'back_to_items' => __( '← Go to Brands', 'woocommerce' ),
+ ),
+
+ 'show_ui' => true,
+ 'show_admin_column' => true,
+ 'show_in_nav_menus' => true,
+ 'show_in_rest' => true,
+ 'capabilities' => array(
+ 'manage_terms' => 'manage_product_terms',
+ 'edit_terms' => 'edit_product_terms',
+ 'delete_terms' => 'delete_product_terms',
+ 'assign_terms' => 'assign_product_terms',
+ ),
+
+ 'rewrite' => array(
+ 'slug' => $slug,
+ 'with_front' => false,
+ 'hierarchical' => true,
+ ),
+ )
+ )
+ );
+ }
+
+ /**
+ * Initializes brand widgets.
+ */
+ public function init_widgets() {
+ // Include.
+ require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-description.php';
+ require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-nav.php';
+ require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-thumbnails.php';
+
+ // Register.
+ register_widget( 'WC_Widget_Brand_Description' );
+ register_widget( 'WC_Widget_Brand_Nav' );
+ register_widget( 'WC_Widget_Brand_Thumbnails' );
+ }
+
+ /**
+ *
+ * Handles template usage so that we can use our own templates instead of the themes.
+ *
+ * Templates are in the 'templates' folder. woocommerce looks for theme
+ * overides in /theme/woocommerce/ by default
+ *
+ * For beginners, it also looks for a woocommerce.php template first. If the user adds
+ * this to the theme (containing a woocommerce() inside) this will be used for all
+ * woocommerce templates.
+ *
+ * @param string $template Template.
+ */
+ public function template_loader( $template ) {
+ $find = array( 'woocommerce.php' );
+ $file = '';
+
+ if ( is_tax( 'product_brand' ) ) {
+
+ $term = get_queried_object();
+
+ $file = 'taxonomy-' . $term->taxonomy . '.php';
+ $find[] = 'taxonomy-' . $term->taxonomy . '-' . $term->slug . '.php';
+ $find[] = $this->template_url . 'taxonomy-' . $term->taxonomy . '-' . $term->slug . '.php';
+ $find[] = $file;
+ $find[] = $this->template_url . $file;
+
+ }
+
+ if ( $file ) {
+ $template = locate_template( $find );
+ if ( ! $template ) {
+ $template = WC()->plugin_path() . '/templates/brands/' . $file;
+ }
+ }
+
+ return $template;
+ }
+
+ /**
+ * Displays brand description.
+ */
+ public function brand_description() {
+ if ( ! is_tax( 'product_brand' ) ) {
+ return;
+ }
+
+ if ( ! get_query_var( 'term' ) ) {
+ return;
+ }
+
+ $thumbnail = '';
+
+ $term = get_term_by( 'slug', get_query_var( 'term' ), 'product_brand' );
+ $thumbnail = wc_get_brand_thumbnail_url( $term->term_id, 'full' );
+
+ wc_get_template(
+ 'brand-description.php',
+ array(
+ 'thumbnail' => $thumbnail,
+ ),
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+ }
+
+ /**
+ * Displays brand.
+ */
+ public function show_brand() {
+ global $post;
+
+ if ( is_singular( 'product' ) ) {
+ $terms = get_the_terms( $post->ID, 'product_brand' );
+ $brand_count = is_array( $terms ) ? count( $terms ) : 0;
+
+ $taxonomy = get_taxonomy( 'product_brand' );
+ $labels = $taxonomy->labels;
+
+ /* translators: %s - Label name */
+ echo wc_get_brands( $post->ID, ', ', ' ' . sprintf( _n( '%s: ', '%s: ', $brand_count, 'woocommerce' ), $labels->singular_name, $labels->name ), ' ' ); // phpcs:ignore WordPress.Security.EscapeOutput
+ }
+ }
+
+ /**
+ * Add structured data to product page.
+ *
+ * @param array $markup Markup.
+ * @return array $markup
+ */
+ public function add_structured_data( $markup ) {
+ global $post;
+
+ if ( array_key_exists( 'brand', $markup ) ) {
+ return $markup;
+ }
+
+ $brands = get_the_terms( $post->ID, 'product_brand' );
+
+ if ( ! empty( $brands ) && is_array( $brands ) ) {
+ // Can only return one brand, so pick the first.
+ $markup['brand'] = array(
+ '@type' => 'Brand',
+ 'name' => $brands[0]->name,
+ );
+ }
+
+ return $markup;
+ }
+
+ /**
+ * Registers shortcodes.
+ */
+ public function register_shortcodes() {
+ add_shortcode( 'product_brand', array( $this, 'output_product_brand' ) );
+ add_shortcode( 'product_brand_thumbnails', array( $this, 'output_product_brand_thumbnails' ) );
+ add_shortcode( 'product_brand_thumbnails_description', array( $this, 'output_product_brand_thumbnails_description' ) );
+ add_shortcode( 'product_brand_list', array( $this, 'output_product_brand_list' ) );
+ add_shortcode( 'brand_products', array( $this, 'output_brand_products' ) );
+ }
+
+ /**
+ * Displays product brand.
+ *
+ * @param array $atts Attributes from the shortcode.
+ * @return string The generated output.
+ */
+ public function output_product_brand( $atts ) {
+ global $post;
+
+ $args = shortcode_atts(
+ array(
+ 'width' => '',
+ 'height' => '',
+ 'class' => 'aligncenter',
+ 'post_id' => '',
+ ),
+ $atts
+ );
+
+ if ( ! $args['post_id'] && ! $post ) {
+ return '';
+ }
+
+ if ( ! $args['post_id'] ) {
+ $args['post_id'] = $post->ID;
+ }
+
+ $brands = wp_get_post_terms( $args['post_id'], 'product_brand', array( 'fields' => 'ids' ) );
+
+ // Bail early if we don't have any brands registered.
+ if ( 0 === count( $brands ) ) {
+ return '';
+ }
+
+ ob_start();
+
+ foreach ( $brands as $brand ) {
+ $thumbnail = wc_get_brand_thumbnail_url( $brand );
+ if ( empty( $thumbnail ) ) {
+ continue;
+ }
+
+ $args['thumbnail'] = $thumbnail;
+ $args['term'] = get_term_by( 'id', $brand, 'product_brand' );
+
+ if ( $args['width'] || $args['height'] ) {
+ $args['width'] = ! empty( $args['width'] ) ? $args['width'] : 'auto';
+ $args['height'] = ! empty( $args['height'] ) ? $args['height'] : 'auto';
+ }
+
+ wc_get_template(
+ 'shortcodes/single-brand.php',
+ $args,
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+ }
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Displays product brand list.
+ *
+ * @param array $atts Attributes from the shortcode.
+ * @return string
+ */
+ public function output_product_brand_list( $atts ) {
+ $args = shortcode_atts(
+ array(
+ 'show_top_links' => true,
+ 'show_empty' => true,
+ 'show_empty_brands' => false,
+ ),
+ $atts
+ );
+
+ $show_top_links = $args['show_top_links'];
+ $show_empty = $args['show_empty'];
+ $show_empty_brands = $args['show_empty_brands'];
+
+ if ( 'false' === $show_top_links ) {
+ $show_top_links = false;
+ }
+
+ if ( 'false' === $show_empty ) {
+ $show_empty = false;
+ }
+
+ if ( 'false' === $show_empty_brands ) {
+ $show_empty_brands = false;
+ }
+
+ $product_brands = array();
+ //phpcs:disable
+ $terms = get_terms( array( 'taxonomy' => 'product_brand', 'hide_empty' => ( $show_empty_brands ? false : true ) ) );
+ $alphabet = apply_filters( 'woocommerce_brands_list_alphabet', range( 'a', 'z' ) );
+ $numbers = apply_filters( 'woocommerce_brands_list_numbers', '0-9' );
+
+ /**
+ * Check for empty brands and remove them from the list.
+ */
+ if ( ! $show_empty_brands ) {
+ $terms = $this->remove_terms_with_empty_products( $terms );
+ }
+
+ foreach ( $terms as $term ) {
+ $term_letter = $this->get_brand_name_first_character( $term->name );
+
+ // Allow a locale to be set for ctype_alpha().
+ if ( has_filter( 'woocommerce_brands_list_locale' ) ) {
+ setLocale( LC_CTYPE, apply_filters( 'woocommerce_brands_list_locale', 'en_US.UTF-8' ) );
+ }
+
+ if ( ctype_alpha( $term_letter ) ) {
+
+ foreach ( $alphabet as $i ) {
+ if ( $i == $term_letter ) {
+ $product_brands[ $i ][] = $term;
+ break;
+ }
+ }
+ } else {
+ $product_brands[ $numbers ][] = $term;
+ }
+ }
+
+ ob_start();
+
+ wc_get_template(
+ 'shortcodes/brands-a-z.php',
+ array(
+ 'terms' => $terms,
+ 'index' => array_merge( $alphabet, array( $numbers ) ),
+ 'product_brands' => $product_brands,
+ 'show_empty' => $show_empty,
+ 'show_top_links' => $show_top_links,
+ ),
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Get the first letter of the brand name, returning lowercase and without accents.
+ *
+ * @param string $name
+ *
+ * @return string
+ * @since 9.4.0
+ */
+ private function get_brand_name_first_character( $name ) {
+ // Convert to lowercase and remove accents.
+ $clean_name = strtolower( sanitize_title( $name ) );
+ // Return the first letter of the name.
+ return substr( $clean_name, 0, 1 );
+ }
+
+ /**
+ * Displays brand thumbnails.
+ *
+ * @param mixed $atts
+ * @return void
+ */
+ public function output_product_brand_thumbnails( $atts ) {
+ $args = shortcode_atts(
+ array(
+ 'show_empty' => true,
+ 'columns' => 4,
+ 'hide_empty' => 0,
+ 'orderby' => 'name',
+ 'exclude' => '',
+ 'number' => '',
+ 'fluid_columns' => false,
+ ),
+ $atts
+ );
+
+ $exclude = array_map( 'intval', explode( ',', $args['exclude'] ) );
+ $order = 'name' === $args['orderby'] ? 'asc' : 'desc';
+
+ if ( 'true' === $args['show_empty'] ) {
+ $hide_empty = false;
+ } else {
+ $hide_empty = true;
+ }
+
+ $brands = get_terms(
+ 'product_brand',
+ array(
+ 'hide_empty' => $hide_empty,
+ 'orderby' => $args['orderby'],
+ 'exclude' => $exclude,
+ 'number' => $args['number'],
+ 'order' => $order,
+ )
+ );
+
+ if ( ! $brands ) {
+ return;
+ }
+
+ if ( $hide_empty ) {
+ $brands = $this->remove_terms_with_empty_products( $brands );
+ }
+
+ ob_start();
+
+ wc_get_template(
+ 'widgets/brand-thumbnails.php',
+ array(
+ 'brands' => $brands,
+ 'columns' => is_numeric( $args['columns'] ) ? intval( $args['columns'] ) : 4,
+ 'fluid_columns' => wp_validate_boolean( $args['fluid_columns'] ),
+ ),
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Displays brand thumbnails description.
+ *
+ * @param mixed $atts
+ * @return void
+ */
+ public function output_product_brand_thumbnails_description( $atts ) {
+ $args = shortcode_atts(
+ array(
+ 'show_empty' => true,
+ 'columns' => 1,
+ 'hide_empty' => 0,
+ 'orderby' => 'name',
+ 'exclude' => '',
+ 'number' => '',
+ ),
+ $atts
+ );
+
+ $exclude = array_map( 'intval', explode( ',', $args['exclude'] ) );
+ $order = 'name' === $args['orderby'] ? 'asc' : 'desc';
+
+ if ( 'true' === $args['show_empty'] ) {
+ $hide_empty = false;
+ } else {
+ $hide_empty = true;
+ }
+
+ $brands = get_terms(
+ 'product_brand',
+ array(
+ 'hide_empty' => $args['hide_empty'],
+ 'orderby' => $args['orderby'],
+ 'exclude' => $exclude,
+ 'number' => $args['number'],
+ 'order' => $order,
+ )
+ );
+
+ if ( ! $brands ) {
+ return;
+ }
+
+ if ( $hide_empty ) {
+ $brands = $this->remove_terms_with_empty_products( $brands );
+ }
+
+ ob_start();
+
+ wc_get_template(
+ 'widgets/brand-thumbnails-description.php',
+ array(
+ 'brands' => $brands,
+ 'columns' => $args['columns'],
+ ),
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Displays brand products.
+ *
+ * @param array $atts
+ * @return string
+ */
+ public function output_brand_products( $atts ) {
+ if ( empty( $atts['brand'] ) ) {
+ return '';
+ }
+
+ // Add the brand attributes and query arguments.
+ add_filter( 'shortcode_atts_brand_products', array( __CLASS__, 'add_brand_products_shortcode_atts' ), 10, 4 );
+ add_filter( 'woocommerce_shortcode_products_query', array( __CLASS__, 'get_brand_products_query_args' ), 10, 3 );
+
+ $shortcode = new WC_Shortcode_Products( $atts, 'brand_products' );
+
+ // Remove the brand attributes and query arguments.
+ remove_filter( 'shortcode_atts_brand_products', array( __CLASS__, 'add_brand_products_shortcode_atts' ), 10 );
+ remove_filter( 'woocommerce_shortcode_products_query', array( __CLASS__, 'get_brand_products_query_args' ), 10 );
+
+ return $shortcode->get_content();
+ }
+
+ /**
+ * Adds the taxonomy query to the WooCommerce products shortcode query arguments.
+ *
+ * @param array $query_args
+ * @param array $attributes
+ * @param string $type
+ *
+ * @return array
+ */
+ public static function get_brand_products_query_args( $query_args, $attributes, $type ) {
+ if ( 'brand_products' !== $type || empty( $attributes['brand'] ) ) {
+ return $query_args;
+ }
+
+ $query_args['tax_query'][] = array(
+ 'taxonomy' => 'product_brand',
+ 'terms' => array_map( 'sanitize_title', explode( ',', $attributes['brand'] ) ),
+ 'field' => 'slug',
+ 'operator' => 'IN',
+ );
+
+ return $query_args;
+ }
+
+ /**
+ * Adds the "brand" attribute to the list of WooCommerce products shortcode attributes.
+ *
+ * @param array $out The output array of shortcode attributes.
+ * @param array $pairs The supported attributes and their defaults.
+ * @param array $atts The user defined shortcode attributes.
+ * @param string $shortcode The shortcode name.
+ *
+ * @return array The output array of shortcode attributes.
+ */
+ public static function add_brand_products_shortcode_atts( $out, $pairs, $atts, $shortcode ) {
+ $out['brand'] = array_key_exists( 'brand', $atts ) ? $atts['brand'] : '';
+
+ return $out;
+ }
+
+ /**
+ * Register REST API route for /products/brands.
+ *
+ * @since 9.4.0
+ *
+ * @return void
+ */
+ public function rest_api_register_routes() {
+ require_once WC()->plugin_path() . '/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php';
+ require_once WC()->plugin_path() . '/includes/rest-api/Controllers/Version3/class-wc-rest-product-brands-controller.php';
+
+ $controllers = array(
+ 'WC_REST_Product_Brands_V2_Controller',
+ 'WC_REST_Product_Brands_Controller'
+ );
+
+ foreach ( $controllers as $controller ) {
+ ( new $controller() )->register_routes();
+ }
+ }
+
+ /**
+ * Maybe set brands when requesting PUT /products/.
+ *
+ * @since 9.4.0
+ *
+ * @param WP_Post $post Post object
+ * @param WP_REST_Request $request Request object
+ *
+ * @return void
+ */
+ public function rest_api_maybe_set_brands( $post, $request ) {
+ if ( isset( $request['brands'] ) && is_array( $request['brands'] ) ) {
+ $terms = array_map( 'absint', $request['brands'] );
+ wp_set_object_terms( $post->ID, $terms, 'product_brand' );
+ }
+ }
+
+ /**
+ * Prepare brands in product response.
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post|WC_Data $post Post object or WC object.
+ * @version 9.4.0
+ * @return WP_REST_Response
+ */
+ public function rest_api_prepare_brands_to_product( $response, $post ) {
+ $post_id = is_callable( array( $post, 'get_id' ) ) ? $post->get_id() : ( ! empty( $post->ID ) ? $post->ID : null );
+
+ if ( empty( $response->data['brands'] ) ) {
+ $terms = array();
+
+ foreach ( wp_get_post_terms( $post_id, 'product_brand' ) as $term ) {
+ $terms[] = array(
+ 'id' => $term->term_id,
+ 'name' => $term->name,
+ 'slug' => $term->slug,
+ );
+ }
+
+ $response->data['brands'] = $terms;
+ }
+
+ return $response;
+ }
+
+ /**
+ * Add brands in product response.
+ *
+ * @param WC_Data $product Inserted product object.
+ * @param WP_REST_Request $request Request object.
+ * @param boolean $creating True when creating object, false when updating.
+ * @version 9.4.0
+ */
+ public function rest_api_add_brands_to_product( $product, $request, $creating = true ) {
+ $product_id = is_callable( array( $product, 'get_id' ) ) ? $product->get_id() : ( ! empty( $product->ID ) ? $product->ID : null );
+ $params = $request->get_params();
+ $brands = isset( $params['brands'] ) ? $params['brands'] : array();
+
+ if ( ! empty( $brands ) ) {
+ if ( is_array( $brands[0] ) && array_key_exists( 'id', $brands[0] ) ) {
+ $brands = array_map(
+ function ( $brand ) {
+ return absint( $brand['id'] );
+ },
+ $brands
+ );
+ } else {
+ $brands = array_map( 'absint', $brands );
+ }
+ wp_set_object_terms( $product_id, $brands, 'product_brand' );
+ }
+ }
+
+ /**
+ * Filters products by taxonomy product_brand.
+ *
+ * @param array $args Request args.
+ * @param WP_REST_Request $request Request data.
+ * @return array Request args.
+ * @version 9.4.0
+ */
+ public function rest_api_filter_products_by_brand( $args, $request ) {
+ if ( ! empty( $request['brand'] ) ) {
+ $args['tax_query'][] = array(
+ 'taxonomy' => 'product_brand',
+ 'field' => 'term_id',
+ 'terms' => $request['brand'],
+ );
+ }
+
+ return $args;
+ }
+
+ /**
+ * Documents additional query params for collections of products.
+ *
+ * @param array $params JSON Schema-formatted collection parameters.
+ * @param WP_Post_Type $post_type Post type object.
+ * @return array JSON Schema-formatted collection parameters.
+ * @version 9.4.0
+ */
+ public function rest_api_product_collection_params( $params, $post_type ) {
+ $params['brand'] = array(
+ 'description' => __( 'Limit result set to products assigned a specific brand ID.', 'woocommerce' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'wp_parse_id_list',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ return $params;
+ }
+
+ /**
+ * Injects Brands filters into layered nav links.
+ *
+ * @param string $term_html Original link html.
+ * @param mixed $term Term that is currently added.
+ * @param string $link Original layered nav item link.
+ * @param number $count Number of items in that filter.
+ * @return string Term html.
+ * @version 9.4.0
+ */
+ public function woocommerce_brands_update_layered_nav_link( $term_html, $term, $link, $count ) {
+ if ( empty( $_GET['filter_product_brand'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return $term_html;
+ }
+
+ $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $current_attributes = array_map( 'intval', explode( ',', $filter_product_brand ) );
+ $current_values = ! empty( $current_attributes ) ? $current_attributes : array();
+ $link = add_query_arg(
+ array(
+ 'filtering' => '1',
+ 'filter_product_brand' => implode( ',', $current_values ),
+ ),
+ wp_specialchars_decode( $link )
+ );
+ $term_html = '' . esc_html( $term->name ) . ' ';
+ $term_html .= ' ' . apply_filters( 'woocommerce_layered_nav_count', '(' . absint( $count ) . ') ', $count, $term );
+ return $term_html;
+ }
+
+ /**
+ * Temporarily tag a post with meta before it is saved in order
+ * to allow us to be able to use the meta when the product is saved to add
+ * the brands when an ID has been generated.
+ *
+ *
+ * @param WC_Product $duplicate
+ * @return WC_Product $original
+ */
+ public function duplicate_store_temporary_brands( $duplicate, $original ) {
+ $terms = get_the_terms( $original->get_id(), 'product_brand' );
+ if ( ! is_array( $terms ) ) {
+ return;
+ }
+
+ $ids = array();
+ foreach ( $terms as $term ) {
+ $ids[] = $term->term_id;
+ }
+ $duplicate->add_meta_data( 'duplicate_temp_brand_ids', $ids );
+ }
+
+ /**
+ * After product was added check if there are temporary brands and
+ * add them officially and remove the temporary brands.
+ *
+ * @since 9.4.0
+ *
+ * @param int $product_id
+ */
+ public function duplicate_add_product_brand_terms( $product_id ) {
+ $product = wc_get_product( $product_id );
+ // Bail if product isn't found.
+ if ( ! $product instanceof WC_Product ) {
+ return;
+ }
+ $term_ids = $product->get_meta( 'duplicate_temp_brand_ids' );
+ if ( empty( $term_ids ) ) {
+ return;
+ }
+ $term_taxonomy_ids = wp_set_object_terms( $product_id, $term_ids, 'product_brand' );
+ $product->delete_meta_data( 'duplicate_temp_brand_ids' );
+ $product->save();
+ }
+
+ /**
+ * Remove terms with empty products.
+ *
+ * @param WP_Term[] $terms The terms array that needs to be removed of empty products.
+ *
+ * @return WP_Term[]
+ */
+ private function remove_terms_with_empty_products( $terms ) {
+ return array_filter(
+ $terms,
+ function ( $term ) {
+ return $term->count > 0;
+ }
+ );
+ }
+
+ /**
+ * Invalidates the layered nav counts cache.
+ *
+ * @return void
+ */
+ public function invalidate_wc_layered_nav_counts_cache() {
+ $taxonomy = 'product_brand';
+ delete_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) );
+ }
+
+ /**
+ * Reset Layered Nav cached counts on product status change.
+ *
+ * @param $new_status
+ * @param $old_status
+ * @param $post
+ *
+ * @return void
+ */
+ function reset_layered_nav_counts_on_status_change( $new_status, $old_status, $post ) {
+ if ( $post->post_type === 'product' && $old_status !== $new_status ) {
+ $this->invalidate_wc_layered_nav_counts_cache();
+ }
+ }
+
+ /**
+ * Add a new block to the template.
+ *
+ * @param string $template_id Template ID.
+ * @param string $template_area Template area.
+ * @param BlockTemplateInterface $template Template instance.
+ */
+ public function wc_brands_on_block_template_register( $template_id, $template_area, $template ) {
+
+ if ( 'simple-product' === $template->get_id() ) {
+ $section = $template->get_section_by_id( 'product-catalog-section' );
+ if ( $section !== null ) {
+ $section->add_block(
+ array(
+ 'id' => 'woocommerce-brands-select',
+ 'blockName' => 'woocommerce/product-taxonomy-field',
+ 'order' => 15,
+ 'attributes' => array(
+ 'label' => __( 'Brands', 'woocommerce-brands' ),
+ 'createTitle' => __( 'Create new brand', 'woocommerce-brands' ),
+ 'slug' => 'product_brand',
+ 'property' => 'brands',
+ ),
+ )
+ );
+ }
+ }
+ }
+}
+
+$GLOBALS['WC_Brands'] = new WC_Brands();
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php
new file mode 100644
index 00000000000..dc66380c2cd
--- /dev/null
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php
@@ -0,0 +1,40 @@
+term_id, 'thumbnail_id', true );
+
+ if ( '' === $size || 'brand-thumb' === $size ) {
+ /**
+ * Filter the brand's thumbnail size.
+ *
+ * @since 9.4.0
+ *
+ * @param string $size Brand's thumbnail size.
+ */
+ $size = apply_filters( 'woocommerce_brand_thumbnail_size', 'shop_catalog' );
+ }
+
+ if ( $thumbnail_id ) {
+ $image_src = wp_get_attachment_image_src( $thumbnail_id, $size );
+ $image_src = $image_src[0];
+ $dimensions = wc_get_image_size( $size );
+ $image_srcset = function_exists( 'wp_get_attachment_image_srcset' ) ? wp_get_attachment_image_srcset( $thumbnail_id, $size ) : false;
+ $image_sizes = function_exists( 'wp_get_attachment_image_sizes' ) ? wp_get_attachment_image_sizes( $thumbnail_id, $size ) : false;
+ } else {
+ $image_src = wc_placeholder_img_src();
+ $dimensions = wc_get_image_size( $size );
+ $image_srcset = false;
+ $image_sizes = false;
+ }
+
+ // Add responsive image markup if available.
+ if ( $image_srcset && $image_sizes ) {
+ $image = ' ';
+ } else {
+ $image = ' ';
+ }
+
+ return $image;
+}
+
+/**
+ * Retrieves product's brands.
+ *
+ * @param int $post_id Post ID (default: 0).
+ * @param string $sep Seperator (default: ').
+ * @param string $before Before item (default: '').
+ * @param string $after After item (default: '').
+ * @return array List of terms
+ */
+function wc_get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) {
+ global $post;
+
+ if ( ! $post_id ) {
+ $post_id = $post->ID;
+ }
+
+ return get_the_term_list( $post_id, 'product_brand', $before, $sep, $after );
+}
+
+/**
+ * Polyfills for backwards compatibility with the WooCommerce Brands plugin.
+ */
+
+if ( ! function_exists( 'get_brand_thumbnail_url' ) ) {
+
+ /**
+ * Polyfill for get_brand_thumbnail_image.
+ *
+ * @param int $brand_id Brand ID.
+ * @param string $size Thumbnail image size.
+ * @return string
+ */
+ function get_brand_thumbnail_url( $brand_id, $size = 'full' ) {
+ return wc_get_brand_thumbnail_url( $brand_id, $size );
+ }
+}
+
+if ( ! function_exists( 'get_brand_thumbnail_image' ) ) {
+
+ /**
+ * Polyfill for get_brand_thumbnail_image.
+ *
+ * @param object $brand Brand term.
+ * @param string $size Thumbnail image size.
+ * @return string
+ */
+ function get_brand_thumbnail_image( $brand, $size = '' ) {
+ return wc_get_brand_thumbnail_image( $brand, $size );
+ }
+}
+
+if ( ! function_exists( 'get_brands' ) ) {
+
+ /**
+ * Polyfill for get_brands.
+ *
+ * @param int $post_id Post ID (default: 0).
+ * @param string $sep Seperator (default: ').
+ * @param string $before Before item (default: '').
+ * @param string $after After item (default: '').
+ * @return array List of terms
+ */
+ function get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) {
+ return wc_get_brands( $post_id, $sep, $before, $after );
+ }
+}
diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php
new file mode 100644
index 00000000000..6f117274f5c
--- /dev/null
+++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php
@@ -0,0 +1,130 @@
+woo_widget_name = __( 'WooCommerce Brand Description', 'woocommerce' );
+ $this->woo_widget_description = __( 'When viewing a brand archive, show the current brands description.', 'woocommerce' );
+ $this->woo_widget_idbase = 'wc_brands_brand_description';
+ $this->woo_widget_cssclass = 'widget_brand_description';
+
+ /* Widget settings. */
+ $widget_ops = array(
+ 'classname' => $this->woo_widget_cssclass,
+ 'description' => $this->woo_widget_description,
+ );
+
+ /* Create the widget. */
+ parent::__construct( $this->woo_widget_idbase, $this->woo_widget_name, $widget_ops );
+ }
+
+ /**
+ * Echoes the widget content.
+ *
+ * @see WP_Widget
+ *
+ * @param array $args Display arguments including 'before_title', 'after_title',
+ * 'before_widget', and 'after_widget'.
+ * @param array $instance The settings for the particular instance of the widget.
+ */
+ public function widget( $args, $instance ) {
+ extract( $args ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract
+
+ if ( ! is_tax( 'product_brand' ) ) {
+ return;
+ }
+
+ if ( ! get_query_var( 'term' ) ) {
+ return;
+ }
+
+ $thumbnail = '';
+ $term = get_term_by( 'slug', get_query_var( 'term' ), 'product_brand' );
+
+ $thumbnail = wc_get_brand_thumbnail_url( $term->term_id, 'large' );
+
+ echo $before_widget . $before_title . $term->name . $after_title; // phpcs:ignore WordPress.Security.EscapeOutput
+
+ wc_get_template(
+ 'widgets/brand-description.php',
+ array(
+ 'thumbnail' => $thumbnail,
+ 'brand' => $term,
+ ),
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+
+ echo $after_widget; // phpcs:ignore WordPress.Security.EscapeOutput
+ }
+
+ /**
+ * Updates widget instance.
+ *
+ * @see WP_Widget->update
+ *
+ * @param array $new_instance New widget instance.
+ * @param array $old_instance Old widget instance.
+ */
+ public function update( $new_instance, $old_instance ) {
+ $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) );
+ return $instance;
+ }
+
+ /**
+ * Outputs the settings update form.
+ *
+ * @param array $instance Current settings.
+ */
+ public function form( $instance ) {
+ ?>
+
+
+
+
+ widget_cssclass = 'woocommerce widget_brand_nav widget_layered_nav';
+ $this->widget_description = __( 'Shows brands in a widget which lets you narrow down the list of products when viewing products.', 'woocommerce' );
+ $this->widget_id = 'woocommerce_brand_nav';
+ $this->widget_name = __( 'WooCommerce Brand Layered Nav', 'woocommerce' );
+
+ add_filter( 'woocommerce_product_subcategories_args', array( $this, 'filter_out_cats' ) );
+
+ /* Create the widget. */
+ parent::__construct();
+ }
+
+ /**
+ * Filter out all categories and not display them
+ *
+ * @param array $cat_args Category arguments.
+ */
+ public function filter_out_cats( $cat_args ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! empty( $_GET['filter_product_brand'] ) ) {
+ return array( 'taxonomy' => '' );
+ }
+
+ return $cat_args;
+ }
+
+ /**
+ * Return the currently viewed taxonomy name.
+ *
+ * @return string
+ */
+ protected function get_current_taxonomy() {
+ return is_tax() ? get_queried_object()->taxonomy : '';
+ }
+
+ /**
+ * Return the currently viewed term ID.
+ *
+ * @return int
+ */
+ protected function get_current_term_id() {
+ return absint( is_tax() ? get_queried_object()->term_id : 0 );
+ }
+
+ /**
+ * Return the currently viewed term slug.
+ *
+ * @return int
+ */
+ protected function get_current_term_slug() {
+ return absint( is_tax() ? get_queried_object()->slug : 0 );
+ }
+
+ /**
+ * Widget function.
+ *
+ * @see WP_Widget
+ *
+ * @param array $args Arguments.
+ * @param array $instance Widget instance.
+ * @return void
+ */
+ public function widget( $args, $instance ) {
+ $attribute_array = array();
+ $attribute_taxonomies = wc_get_attribute_taxonomies();
+
+ if ( ! empty( $attribute_taxonomies ) ) {
+ foreach ( $attribute_taxonomies as $tax ) {
+ if ( taxonomy_exists( wc_attribute_taxonomy_name( $tax->attribute_name ) ) ) {
+ $attribute_array[ $tax->attribute_name ] = $tax->attribute_name;
+ }
+ }
+ }
+
+ if ( ! is_post_type_archive( 'product' ) && ! is_tax( array_merge( is_array( $attribute_array ) ? $attribute_array : array(), array( 'product_cat', 'product_tag' ) ) ) ) {
+ return;
+ }
+
+ $_chosen_attributes = WC_Query::get_layered_nav_chosen_attributes();
+
+ $current_term = $attribute_array && is_tax( $attribute_array ) ? get_queried_object()->term_id : '';
+ $current_tax = $attribute_array && is_tax( $attribute_array ) ? get_queried_object()->taxonomy : '';
+
+ /**
+ * Filter the widget's title.
+ *
+ * @since 9.4.0
+ *
+ * @param string $title Widget title
+ * @param array $instance The settings for the particular instance of the widget.
+ * @param string $woo_widget_idbase The widget's id base.
+ */
+ $title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );
+ $taxonomy = 'product_brand';
+ $display_type = isset( $instance['display_type'] ) ? $instance['display_type'] : 'list';
+
+ if ( ! taxonomy_exists( $taxonomy ) ) {
+ return;
+ }
+
+ // Get only parent terms. Methods will recursively retrieve children.
+ $terms = get_terms(
+ array(
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => true,
+ 'parent' => 0,
+ )
+ );
+
+ if ( empty( $terms ) ) {
+ return;
+ }
+
+ ob_start();
+
+ $this->widget_start( $args, $instance );
+
+ if ( 'dropdown' === $display_type ) {
+ $found = $this->layered_nav_dropdown( $terms, $taxonomy );
+ } else {
+ $found = $this->layered_nav_list( $terms, $taxonomy );
+ }
+
+ $this->widget_end( $args );
+
+ // Force found when option is selected - do not force found on taxonomy attributes.
+ if ( ! is_tax() && is_array( $_chosen_attributes ) && array_key_exists( $taxonomy, $_chosen_attributes ) ) {
+ $found = true;
+ }
+
+ if ( ! $found ) {
+ ob_end_clean();
+ } else {
+ echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput
+ }
+ }
+
+ /**
+ * Update function.
+ *
+ * @see WP_Widget->update
+ *
+ * @param array $new_instance The new settings for the particular instance of the widget.
+ * @param array $old_instance The old settings for the particular instance of the widget.
+ * @return array
+ */
+ public function update( $new_instance, $old_instance ) {
+ global $woocommerce;
+
+ if ( empty( $new_instance['title'] ) ) {
+ $new_instance['title'] = __( 'Brands', 'woocommerce' );
+ }
+
+ $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) );
+ $instance['display_type'] = stripslashes( $new_instance['display_type'] );
+
+ return $instance;
+ }
+
+ /**
+ * Form function.
+ *
+ * @see WP_Widget->form
+ *
+ * @param array $instance Widget instance.
+ * @return void
+ */
+ public function form( $instance ) {
+ global $woocommerce;
+
+ if ( ! isset( $instance['display_type'] ) ) {
+ $instance['display_type'] = 'list';
+ }
+ ?>
+
+
+
+
+
+
+ >
+ >
+
+ $data ) {
+ if ( $name === $taxonomy ) {
+ continue;
+ }
+ $filter_name = sanitize_title( str_replace( 'pa_', '', $name ) );
+ if ( ! empty( $data['terms'] ) ) {
+ $link = add_query_arg( 'filter_' . $filter_name, implode( ',', $data['terms'] ), $link );
+ }
+ if ( 'or' === $data['query_type'] ) {
+ $link = add_query_arg( 'query_type_' . $filter_name, 'or', $link );
+ }
+ }
+ }
+
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+ return esc_url( $link );
+ }
+
+ /**
+ * Gets the currently selected attributes
+ *
+ * @return array
+ */
+ public function get_chosen_attributes() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! empty( $_GET['filter_product_brand'] ) ) {
+ $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return array_map( 'intval', explode( ',', $filter_product_brand ) );
+ }
+
+ return array();
+ }
+
+ /**
+ * Show dropdown layered nav.
+ *
+ * @param array $terms Terms.
+ * @param string $taxonomy Taxonomy.
+ * @param int $depth Depth.
+ * @return bool Will nav display?
+ */
+ protected function layered_nav_dropdown( $terms, $taxonomy, $depth = 0 ) {
+ $found = false;
+
+ if ( $taxonomy !== $this->get_current_taxonomy() ) {
+ $term_counts = $this->get_filtered_term_product_counts( wp_list_pluck( $terms, 'term_id' ), $taxonomy, 'or' );
+ $_chosen_attributes = $this->get_chosen_attributes();
+
+ if ( 0 === $depth ) {
+ echo '';
+ echo '' . esc_html__( 'Any Brand', 'woocommerce' ) . ' ';
+ }
+
+ foreach ( $terms as $term ) {
+ // If on a term page, skip that term in widget list.
+ if ( $term->term_id === $this->get_current_term_id() ) {
+ continue;
+ }
+
+ // Get count based on current view.
+ $current_values = ! empty( $_chosen_attributes ) ? $_chosen_attributes : array();
+ $option_is_set = in_array( $term->term_id, $current_values, true );
+ $count = isset( $term_counts[ $term->term_id ] ) ? $term_counts[ $term->term_id ] : 0;
+
+ // Only show options with count > 0.
+ if ( 0 < $count ) {
+ $found = true;
+ } elseif ( 0 === $count && ! $option_is_set ) {
+ continue;
+ }
+
+ echo '' . esc_html( str_repeat( ' ', 2 * $depth ) . $term->name ) . ' ';
+
+ $child_terms = get_terms(
+ array(
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => true,
+ 'parent' => $term->term_id,
+ )
+ );
+
+ if ( ! empty( $child_terms ) ) {
+ $found |= $this->layered_nav_dropdown( $child_terms, $taxonomy, $depth + 1 );
+ }
+ }
+
+ if ( 0 === $depth ) {
+ $link = $this->get_page_base_url( $taxonomy );
+ echo ' ';
+
+ wc_enqueue_js(
+ "
+ jQuery( '.wc-brand-dropdown-layered-nav-" . esc_js( $taxonomy ) . "' ).change( function() {
+ var slug = jQuery( this ).val();
+ location.href = '" . preg_replace( '%\/page\/[0-9]+%', '', str_replace( array( '&', '%2C' ), array( '&', ',' ), esc_js( add_query_arg( 'filtering', '1', $link ) ) ) ) . '&filter_' . esc_js( $taxonomy ) . "=' + jQuery( '.wc-brand-dropdown-layered-nav-" . esc_js( $taxonomy ) . "' ).val();
+ });
+ "
+ );
+ }
+ }
+
+ return $found;
+ }
+
+ /**
+ * Show list based layered nav.
+ *
+ * @param array $terms Terms.
+ * @param string $taxonomy Taxonomy.
+ * @param int $depth Depth.
+ * @return bool Will nav display?
+ */
+ protected function layered_nav_list( $terms, $taxonomy, $depth = 0 ) {
+ // List display.
+ echo '';
+
+ $term_counts = $this->get_filtered_term_product_counts( wp_list_pluck( $terms, 'term_id' ), $taxonomy, 'or' );
+ $_chosen_attributes = $this->get_chosen_attributes();
+ $current_values = ! empty( $_chosen_attributes ) ? $_chosen_attributes : array();
+ $found = false;
+
+ $filter_name = 'filter_' . $taxonomy;
+
+ foreach ( $terms as $term ) {
+ $option_is_set = in_array( $term->term_id, $current_values, true );
+ $count = isset( $term_counts[ $term->term_id ] ) ? $term_counts[ $term->term_id ] : 0;
+
+ // skip the term for the current archive.
+ if ( $this->get_current_term_id() === $term->term_id ) {
+ continue;
+ }
+
+ // Only show options with count > 0.
+ if ( 0 < $count ) {
+ $found = true;
+ } elseif ( 0 === $count && ! $option_is_set ) {
+ continue;
+ }
+
+ $current_filter = isset( $_GET[ $filter_name ] ) ? explode( ',', wc_clean( wp_unslash( $_GET[ $filter_name ] ) ) ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $current_filter = array_map( 'intval', $current_filter );
+
+ if ( ! in_array( $term->term_id, $current_filter, true ) ) {
+ $current_filter[] = $term->term_id;
+ }
+
+ $link = $this->get_page_base_url( $taxonomy );
+
+ // Add current filters to URL.
+ foreach ( $current_filter as $key => $value ) {
+ // Exclude query arg for current term archive term.
+ if ( $value === $this->get_current_term_id() ) {
+ unset( $current_filter[ $key ] );
+ }
+
+ // Exclude self so filter can be unset on click.
+ if ( $option_is_set && $value === $term->term_id ) {
+ unset( $current_filter[ $key ] );
+ }
+ }
+
+ if ( ! empty( $current_filter ) ) {
+ $link = add_query_arg(
+ array(
+ 'filtering' => '1',
+ $filter_name => implode( ',', $current_filter ),
+ ),
+ $link
+ );
+ }
+
+ echo '';
+
+ echo ( $count > 0 || $option_is_set ) ? '' : ''; // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ echo esc_html( $term->name );
+
+ echo ( $count > 0 || $option_is_set ) ? ' ' : ' ';
+
+ echo wp_kses_post( apply_filters( 'woocommerce_layered_nav_count', '(' . absint( $count ) . ') ', $count, $term ) );// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ $child_terms = get_terms(
+ array(
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => true,
+ 'parent' => $term->term_id,
+ )
+ );
+
+ if ( ! empty( $child_terms ) ) {
+ $found |= $this->layered_nav_list( $child_terms, $taxonomy, $depth + 1 );
+ }
+
+ echo ' ';
+ }
+
+ echo ' ';
+
+ return $found;
+ }
+
+ /**
+ * Count products within certain terms, taking the main WP query into consideration.
+ *
+ * @param array $term_ids Term IDs.
+ * @param string $taxonomy Taxonomy.
+ * @param string $query_type Query type.
+ * @return array
+ */
+ protected function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type = 'and' ) {
+ global $wpdb;
+
+ $tax_query = WC_Query::get_main_tax_query();
+ $meta_query = WC_Query::get_main_meta_query();
+
+ if ( 'or' === $query_type ) {
+ foreach ( $tax_query as $key => $query ) {
+ if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
+ unset( $tax_query[ $key ] );
+ }
+ }
+ }
+
+ $meta_query = new WP_Meta_Query( $meta_query );
+ $tax_query = new WP_Tax_Query( $tax_query );
+ $meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
+ $tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' );
+
+ // Generate query.
+ $query = array();
+ $query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) as term_count, terms.term_id as term_count_id";
+ $query['from'] = "FROM {$wpdb->posts}";
+ $query['join'] = "
+ INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id
+ INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
+ INNER JOIN {$wpdb->terms} AS terms USING( term_id )
+ " . $tax_query_sql['join'] . $meta_query_sql['join'];
+ $query['where'] = "
+ WHERE {$wpdb->posts}.post_type IN ( 'product' )
+ AND {$wpdb->posts}.post_status = 'publish'
+ " . $tax_query_sql['where'] . $meta_query_sql['where'] . '
+ AND terms.term_id IN (' . implode( ',', array_map( 'absint', $term_ids ) ) . ')
+ ';
+ $query['group_by'] = 'GROUP BY terms.term_id';
+ $query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ $query = implode( ' ', $query );
+
+ // We have a query - let's see if cached results of this query already exist.
+ $query_hash = md5( $query );
+
+ $cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ if ( true === $cache ) {
+ $cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) );
+ } else {
+ $cached_counts = array();
+ }
+
+ if ( ! isset( $cached_counts[ $query_hash ] ) ) {
+ $results = $wpdb->get_results( $query, ARRAY_A ); // @codingStandardsIgnoreLine
+ $counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
+ $cached_counts[ $query_hash ] = $counts;
+ if ( true === $cache ) {
+ set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, HOUR_IN_SECONDS );
+ }
+ }
+
+ return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
+ }
+}
diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php
new file mode 100644
index 00000000000..fd6a07e38f8
--- /dev/null
+++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php
@@ -0,0 +1,235 @@
+woo_widget_name = __( 'WooCommerce Brand Thumbnails', 'woocommerce' );
+ $this->woo_widget_description = __( 'Show a grid of brand thumbnails.', 'woocommerce' );
+ $this->woo_widget_idbase = 'wc_brands_brand_thumbnails';
+ $this->woo_widget_cssclass = 'widget_brand_thumbnails';
+
+ /* Widget settings. */
+ $widget_ops = array(
+ 'classname' => $this->woo_widget_cssclass,
+ 'description' => $this->woo_widget_description,
+ );
+
+ /* Create the widget. */
+ parent::__construct( $this->woo_widget_idbase, $this->woo_widget_name, $widget_ops );
+ }
+
+ /**
+ * Echoes the widget content.
+ *
+ * @see WP_Widget
+ *
+ * @param array $args Display arguments including 'before_title', 'after_title',
+ * 'before_widget', and 'after_widget'.
+ * @param array $instance The settings for the particular instance of the widget.
+ */
+ public function widget( $args, $instance ) {
+ $instance = wp_parse_args(
+ $instance,
+ array(
+ 'title' => '',
+ 'columns' => 1,
+ 'exclude' => '',
+ 'orderby' => 'name',
+ 'hide_empty' => 0,
+ 'number' => '',
+ )
+ );
+
+ $exclude = array_map( 'intval', explode( ',', $instance['exclude'] ) );
+ $order = 'name' === $instance['orderby'] ? 'asc' : 'desc';
+
+ $brands = get_terms(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'hide_empty' => $instance['hide_empty'],
+ 'orderby' => $instance['orderby'],
+ 'exclude' => $exclude,
+ 'number' => $instance['number'],
+ 'order' => $order,
+ )
+ );
+
+ if ( ! $brands ) {
+ return;
+ }
+
+ /**
+ * Filter the widget's title.
+ *
+ * @since 9.4.0
+ *
+ * @param string $title Widget title
+ * @param array $instance The settings for the particular instance of the widget.
+ * @param string $woo_widget_idbase The widget's id base.
+ */
+ $title = apply_filters( 'widget_title', $instance['title'], $instance, $this->woo_widget_idbase );
+
+ echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput
+ if ( '' !== $title ) {
+ echo $args['before_title'] . $title . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput
+ }
+
+ wc_get_template(
+ 'widgets/brand-thumbnails.php',
+ array(
+ 'brands' => $brands,
+ 'columns' => (int) $instance['columns'],
+ 'fluid_columns' => ! empty( $instance['fluid_columns'] ) ? true : false,
+ ),
+ 'woocommerce',
+ WC()->plugin_path() . '/templates/brands/'
+ );
+
+ echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput
+ }
+
+ /**
+ * Update widget instance.
+ *
+ * @param array $new_instance The new settings for the particular instance of the widget.
+ * @param array $old_instance The old settings for the particular instance of the widget.
+ *
+ * @see WP_Widget->update
+ */
+ public function update( $new_instance, $old_instance ) {
+ $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) );
+ $instance['columns'] = wp_strip_all_tags( stripslashes( $new_instance['columns'] ) );
+ $instance['fluid_columns'] = ! empty( $new_instance['fluid_columns'] ) ? true : false;
+ $instance['orderby'] = wp_strip_all_tags( stripslashes( $new_instance['orderby'] ) );
+ $instance['exclude'] = wp_strip_all_tags( stripslashes( $new_instance['exclude'] ) );
+ $instance['hide_empty'] = wp_strip_all_tags( stripslashes( (string) $new_instance['hide_empty'] ) );
+ $instance['number'] = wp_strip_all_tags( stripslashes( $new_instance['number'] ) );
+
+ if ( ! $instance['columns'] ) {
+ $instance['columns'] = 1;
+ }
+
+ if ( ! $instance['orderby'] ) {
+ $instance['orderby'] = 'name';
+ }
+
+ if ( ! $instance['exclude'] ) {
+ $instance['exclude'] = '';
+ }
+
+ if ( ! $instance['hide_empty'] ) {
+ $instance['hide_empty'] = 0;
+ }
+
+ if ( ! $instance['number'] ) {
+ $instance['number'] = '';
+ }
+
+ return $instance;
+ }
+
+ /**
+ * Outputs the settings update form.
+ *
+ * @param array $instance Current settings.
+ */
+ public function form( $instance ) {
+ if ( ! isset( $instance['hide_empty'] ) ) {
+ $instance['hide_empty'] = 0;
+ }
+
+ if ( ! isset( $instance['orderby'] ) ) {
+ $instance['orderby'] = 'name';
+ }
+
+ if ( empty( $instance['fluid_columns'] ) ) {
+ $instance['fluid_columns'] = false;
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ id="get_field_id( 'fluid_columns' ) ); ?>" name="get_field_name( 'fluid_columns' ) ); ?>" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ >
+
+
+
+
+
+
+ >
+ >
+
+
+ '\\Automattic\\WooCommerce\\Admin\\Composer\\Package',
+ 'woocommerce-gutenberg-products-block' => '\\Automattic\\WooCommerce\\Blocks\\Package',
+ );
+
+ /**
+ * Similar to $base_packages, but
+ * the packages included in this array can be deactivated via the 'woocommerce_merged_packages' filter.
+ *
* @var array Key is the package name/directory, value is the main package class which handles init.
*/
protected static $merged_packages = array(
- 'woocommerce-admin' => '\\Automattic\\WooCommerce\\Admin\\Composer\\Package',
- 'woocommerce-gutenberg-products-block' => '\\Automattic\\WooCommerce\\Blocks\\Package',
+ 'woocommerce-brands' => '\\Automattic\\WooCommerce\\Internal\\Brands',
);
+
/**
* Init the package loader.
*
* @since 3.7.0
*/
public static function init() {
- add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ) );
+ add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ), 0 );
+
+ // Prevent plugins already merged into WooCommerce core from getting activated as standalone plugins.
+ add_action( 'activate_plugin', array( __CLASS__, 'deactivate_merged_plugins' ) );
+
+ // Display a notice in the Plugins tab next to plugins already merged into WooCommerce core.
+ add_filter( 'all_plugins', array( __CLASS__, 'mark_merged_plugins_as_pending_update' ), 10, 1 );
+ add_action( 'after_plugin_row', array( __CLASS__, 'display_notice_for_merged_plugins' ), 10, 1 );
}
/**
@@ -74,6 +94,61 @@ class Packages {
return file_exists( dirname( __DIR__ ) . '/packages/' . $package );
}
+ /**
+ * Checks a package exists by looking for it's directory.
+ *
+ * @param string $class_name Class name.
+ * @return boolean
+ */
+ public static function should_load_class( $class_name ) {
+
+ foreach ( self::$merged_packages as $merged_package_name => $merged_package_class ) {
+ if ( str_replace( 'woocommerce-', 'wc_', $merged_package_name ) === $class_name ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Gets all merged, enabled packages.
+ *
+ * @return array
+ */
+ protected static function get_enabled_packages() {
+ $enabled_packages = array();
+
+ foreach ( self::$merged_packages as $merged_package_name => $package_class ) {
+
+ // For gradual rollouts, ensure that a package is enabled for user's remote variant number.
+ $experimental_package_enabled = method_exists( $package_class, 'is_enabled' ) ?
+ call_user_func( array( $package_class, 'is_enabled' ) ) :
+ false;
+
+ if ( ! $experimental_package_enabled ) {
+ continue;
+ }
+
+ $option = 'wc_feature_' . str_replace( '-', '_', $merged_package_name ) . '_enabled';
+ if ( 'yes' === get_option( $option, 'no' ) ) {
+ $enabled_packages[ $merged_package_name ] = $package_class;
+ }
+ }
+
+ return array_merge( $enabled_packages, self::$base_packages );
+ }
+
+ /**
+ * Checks if a package is enabled.
+ *
+ * @param string $package Package name.
+ * @return boolean
+ */
+ public static function is_package_enabled( $package ) {
+ return array_key_exists( $package, self::get_enabled_packages() );
+ }
+
/**
* Deactivates merged feature plugins.
*
@@ -93,7 +168,8 @@ class Packages {
// Deactivate the plugin if possible so that there are no conflicts.
foreach ( $active_plugins as $active_plugin_path ) {
$plugin_file = basename( plugin_basename( $active_plugin_path ), '.php' );
- if ( ! isset( self::$merged_packages[ $plugin_file ] ) ) {
+
+ if ( ! self::is_package_enabled( $plugin_file ) ) {
continue;
}
@@ -107,7 +183,7 @@ class Packages {
function() use ( $plugin_data ) {
echo '';
printf(
- /* translators: %s: is referring to the plugin's name. */
+ /* translators: %s: is referring to the plugin's name. */
esc_html__( 'The %1$s plugin has been deactivated as the latest improvements are now included with the %2$s plugin.', 'woocommerce' ),
'' . esc_html( $plugin_data['Name'] ) . '
',
'WooCommerce
'
@@ -118,13 +194,71 @@ class Packages {
}
}
+ /**
+ * Prevent plugins already merged into WooCommerce core from getting activated as standalone plugins.
+ *
+ * @param string $plugin Plugin name.
+ */
+ public static function deactivate_merged_plugins( $plugin ) {
+ $plugin_dir = basename( dirname( $plugin ) );
+
+ if ( self::is_package_enabled( $plugin_dir ) ) {
+ $plugins_url = esc_url( admin_url( 'plugins.php' ) );
+ wp_die(
+ esc_html__( 'This plugin cannot be activated because its functionality is now included in WooCommerce core.', 'woocommerce' ),
+ esc_html__( 'Plugin Activation Error', 'woocommerce' ),
+ array(
+ 'link_url' => esc_url( $plugins_url ),
+ 'link_text' => esc_html__( 'Return to the Plugins page', 'woocommerce' ),
+ ),
+ );
+ }
+ }
+
+ /**
+ * Mark merged plugins as pending update.
+ * This is required for correctly displaying maintenance notices.
+ *
+ * @param array $plugins Plugins list.
+ */
+ public static function mark_merged_plugins_as_pending_update( $plugins ) {
+ foreach ( $plugins as $plugin_name => $plugin_data ) {
+ $plugin_dir = basename( dirname( $plugin_name ) );
+ if ( self::is_package_enabled( $plugin_dir ) ) {
+ // Necessary to properly display notice within row.
+ $plugins[ $plugin_name ]['update'] = 1;
+ }
+ }
+ return $plugins;
+ }
+
+ /**
+ * Displays a maintenance notice next to merged plugins, to inform users
+ * that the plugin functionality is now offered by WooCommerce core.
+ *
+ * Requires 'mark_merged_plugins_as_pending_update' to properly display this notice.
+ *
+ * @param string $plugin_file Plugin file.
+ */
+ public static function display_notice_for_merged_plugins( $plugin_file ) {
+ global $wp_list_table;
+
+ $plugin_dir = basename( dirname( $plugin_file ) );
+ $columns_count = $wp_list_table->get_column_count();
+ $notice = __( 'This plugin can no longer be activated because its functionality is now included in WooCommerce . It is recommended to delete it.', 'woocommerce' );
+
+ if ( self::is_package_enabled( $plugin_dir ) ) {
+ echo '
' . wp_kses_post( $notice ) . '
';
+ }
+ }
+
/**
* Loads packages after plugins_loaded hook.
*
* Each package should include an init file which loads the package so it can be used by core.
*/
protected static function initialize_packages() {
- foreach ( self::$merged_packages as $package_name => $package_class ) {
+ foreach ( self::get_enabled_packages() as $package_name => $package_class ) {
call_user_func( array( $package_class, 'init' ) );
}
@@ -172,7 +306,7 @@ class Packages {
}
add_action(
'admin_notices',
- function() use ( $package ) {
+ function () use ( $package ) {
?>
diff --git a/plugins/woocommerce/templates/brands/brand-description.php b/plugins/woocommerce/templates/brands/brand-description.php
new file mode 100644
index 00000000000..a72a251a3f6
--- /dev/null
+++ b/plugins/woocommerce/templates/brands/brand-description.php
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php b/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php
new file mode 100644
index 00000000000..ef2d9042a5f
--- /dev/null
+++ b/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+ %s ',
+ esc_url( get_term_link( $brand->slug, 'product_brand' ) ),
+ esc_html( $brand->name )
+ );
+ }
+ ?>
+
+
+
+
+
+
+
+
+
diff --git a/plugins/woocommerce/templates/brands/shortcodes/single-brand.php b/plugins/woocommerce/templates/brands/shortcodes/single-brand.php
new file mode 100644
index 00000000000..556ae2055e9
--- /dev/null
+++ b/plugins/woocommerce/templates/brands/shortcodes/single-brand.php
@@ -0,0 +1,38 @@
+
+
+
+
diff --git a/plugins/woocommerce/templates/brands/taxonomy-product_brand.php b/plugins/woocommerce/templates/brands/taxonomy-product_brand.php
new file mode 100644
index 00000000000..56898bf0cb3
--- /dev/null
+++ b/plugins/woocommerce/templates/brands/taxonomy-product_brand.php
@@ -0,0 +1,12 @@
+
+
diff --git a/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php b/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php
new file mode 100644
index 00000000000..bbfdf43f236
--- /dev/null
+++ b/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php
@@ -0,0 +1,45 @@
+
+
+
+ $brand ) :
+ $class = '';
+ if ( 0 === $index || 0 === $index % $columns ) {
+ $class = 'first';
+ } elseif ( 0 === ( $index + 1 ) % $columns ) {
+ $class = 'last';
+ }
+ ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html b/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html
new file mode 100644
index 00000000000..aa23a7d2ccc
--- /dev/null
+++ b/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/woocommerce/templates/templates/taxonomy-product_brand.html b/plugins/woocommerce/templates/templates/taxonomy-product_brand.html
new file mode 100644
index 00000000000..4cf01077d40
--- /dev/null
+++ b/plugins/woocommerce/templates/templates/taxonomy-product_brand.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js
new file mode 100644
index 00000000000..2288819aa86
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js
@@ -0,0 +1,181 @@
+const { test, expect } = require( '@playwright/test' );
+
+test.use( { storageState: process.env.ADMINSTATE } );
+
+test.skip( 'Merchant can add brands', async ( { page } ) => {
+ /**
+ * Go to the Brands page.
+ *
+ * This will visit the Products page first, and then click on the Brands link.
+ * This is to workaround the hover menu for now.
+ */
+ const goToBrandsPage = async () => {
+ await page.goto(
+ 'wp-admin/edit-tags.php?taxonomy=product_brand&post_type=product'
+ );
+
+ // Wait for the Brands page to load.
+ // This is needed so that checking for existing brands would work.
+ await page.waitForSelector( '.wp-list-table' );
+ };
+
+ const createBrandIfNotExist = async (
+ name,
+ slug,
+ parentBrand,
+ description,
+ thumbnailFileName
+ ) => {
+ // Create "WooCommerce" brand if it does not exist.
+ const cellVisible = await page
+ .locator( '#posts-filter' )
+ .getByRole( 'cell', { name: slug, exact: true } )
+ .isVisible();
+
+ if ( cellVisible ) {
+ return;
+ }
+
+ await page.getByRole( 'textbox', { name: 'Name' } ).click();
+ await page.getByRole( 'textbox', { name: 'Name' } ).fill( name );
+ await page.getByRole( 'textbox', { name: 'Slug' } ).click();
+ await page.getByRole( 'textbox', { name: 'Slug' } ).fill( slug );
+
+ await page
+ .getByRole( 'combobox', { name: 'Parent Brand' } )
+ .selectOption( { label: parentBrand } );
+
+ await page.getByRole( 'textbox', { name: 'Description' } ).click();
+ await page
+ .getByRole( 'textbox', { name: 'Description' } )
+ .fill( description );
+ await page.getByRole( 'button', { name: 'Upload/Add image' } ).click();
+ await page.getByRole( 'tab', { name: 'Media Library' } ).click();
+ await page.getByRole( 'checkbox', { name: thumbnailFileName } ).click();
+ await page.getByRole( 'button', { name: 'Use image' } ).click();
+ await page.getByRole( 'button', { name: 'Add New Brand' } ).click();
+
+ // We should see an "Item added." notice message at the top of the page.
+ await expect(
+ page.locator( '#ajax-response' ).getByText( 'Item added.' )
+ ).toBeVisible();
+
+ // We should see the newly created brand in the Brands table.
+ await expect(
+ page
+ .locator( '#posts-filter' )
+ .getByRole( 'cell', { name: slug, exact: true } )
+ ).toHaveCount( 1 );
+ };
+
+ /**
+ * Edit a brand.
+ *
+ * You must be in the Brands page before calling this function.
+ * To do so, call `goToBrandsPage()` first.
+ *
+ * After a brand is edited, you will be redirected to the Brands page.
+ */
+ const editBrand = async (
+ currentName,
+ { name, slug, parentBrand, description, thumbnailFileName }
+ ) => {
+ await page.getByLabel( `“${ currentName }” (Edit)` ).click();
+ await page.getByLabel( 'Name' ).fill( name );
+ await page.getByLabel( 'Slug' ).fill( slug );
+ await page
+ .getByLabel( 'Parent Brand' )
+ .selectOption( { label: parentBrand } );
+ await page.getByLabel( 'Description' ).fill( description );
+
+ await page.getByRole( 'button', { name: 'Upload/Add image' } ).click();
+ await page.getByRole( 'tab', { name: 'Media Library' } ).click();
+ await page.getByLabel( thumbnailFileName ).click();
+ await page.getByRole( 'button', { name: 'Use image' } ).click();
+
+ await page.getByRole( 'button', { name: 'Update' } ).click();
+
+ // We should see an "Item updated." notice message at the top of the page.
+ await expect(
+ page.locator( '#message' ).getByText( 'Item updated.' )
+ ).toBeVisible();
+
+ // navigate back to Brands page.
+ await page.getByRole( 'link', { name: '← Go to Brands' } ).click();
+
+ // confirm that the brand has been updated.
+ await expect(
+ page
+ .locator( '#posts-filter' )
+ .getByRole( 'cell', { name: slug, exact: true } )
+ ).toHaveCount( 1 );
+ };
+
+ /**
+ * Delete a brand.
+ *
+ * You must be in the Brands page before calling this function.
+ * To do so, call `goToBrandsPage()` first.
+ *
+ * After a brand is deleted, you will be redirected to the Brands page.
+ */
+ const deleteBrand = async ( name ) => {
+ await page.getByLabel( `“${ name }” (Edit)` ).click();
+
+ // After clicking the "Delete" button, there will be a confirmation dialog.
+ page.once( 'dialog', ( dialog ) => {
+ // Click "OK" to confirm the deletion.
+ dialog.accept();
+ } );
+
+ // Click on the "Delete" button.
+ await page.getByRole( 'link', { name: 'Delete' } ).click();
+
+ // We should now be in the Brands page.
+ // Confirm that the brand has been deleted and is no longer in the Brands table.
+ await expect(
+ page
+ .locator( '#posts-filter' )
+ .getByRole( 'cell', { name, exact: true } )
+ ).toHaveCount( 0 );
+ };
+
+ await goToBrandsPage();
+ await createBrandIfNotExist(
+ 'WooCommerce',
+ 'woocommerce',
+ 'None',
+ 'All things WooCommerce!',
+ 'image-01'
+ );
+
+ // Create child brand under the "WooCommerce" parent brand.
+ await createBrandIfNotExist(
+ 'WooCommerce Apparels',
+ 'woocommerce-apparels',
+ 'WooCommerce',
+ 'Cool WooCommerce clothings!',
+ 'image-02'
+ );
+
+ // Create a dummy child brand called "WooCommerce Dummy" under the "WooCommerce" parent brand.
+ await createBrandIfNotExist(
+ 'WooCommerce Dummy',
+ 'woocommerce-dummy',
+ 'WooCommerce',
+ 'Dummy WooCommerce brand!',
+ 'image-02'
+ );
+
+ // Edit the dummy child brand from "WooCommerce Dummy" to "WooCommerce Dummy Edited".
+ await editBrand( 'WooCommerce Dummy', {
+ name: 'WooCommerce Dummy Edited',
+ slug: 'woocommerce-dummy-edited',
+ parentBrand: 'WooCommerce',
+ description: 'Dummy WooCommerce brand edited!',
+ thumbnailFileName: 'image-03',
+ } );
+
+ // Delete the dummy child brand "WooCommerce Dummy Edited".
+ await deleteBrand( 'WooCommerce Dummy Edited' );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js
index 2f26691761b..3fd0b4c1717 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js
+++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js
@@ -37,6 +37,12 @@ const couponData = {
amount: '60',
excludeProductCategories: [ 'Uncategorized' ],
},
+ excludeProductBrands: {
+ code: `excludeProductBrands-${ new Date().getTime().toString() }`,
+ description: 'Exclude product brands coupon',
+ amount: '65',
+ excludeProductBrands: [ 'WooCommerce Apparels' ],
+ },
products: {
code: `products-${ new Date().getTime().toString() }`,
description: 'Products coupon',
@@ -202,6 +208,26 @@ test.describe( 'Restricted coupon management', { tag: [ '@services' ] }, () => {
.click();
} );
}
+
+ // Skip Brands tests while behind a feature flag.
+ const skipBrandsTests = true;
+
+ // set exclude product brands
+ if ( couponType === 'excludeProductBrands' && ! skipBrandsTests ) {
+ await test.step( 'set exclude product brands coupon', async () => {
+ await page
+ .getByRole( 'link', {
+ name: 'Usage restriction',
+ } )
+ .click();
+ await page
+ .getByPlaceholder( 'No brands' )
+ .pressSequentially( 'WooCommerce Apparels' );
+ await page
+ .getByRole( 'option', { name: 'WooCommerce Apparels' } )
+ .click();
+ } );
+ }
// set products
if ( couponType === 'products' ) {
await test.step( 'set products coupon', async () => {
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php
new file mode 100644
index 00000000000..5ca5953daf5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php
@@ -0,0 +1,116 @@
+factory()->term->create(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'name' => 'Blah_A',
+ )
+ );
+ $term_b_id = $this->factory()->term->create(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'name' => 'Foo_A',
+ )
+ );
+ $term_c_id = $this->factory()->term->create(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'name' => 'Blah_B',
+ )
+ );
+
+ wp_set_post_terms( $simple_product->get_id(), array( $term_a_id, $term_b_id, $term_c_id ), 'product_brand' );
+
+ add_filter(
+ 'woocommerce_product_brand_filter_threshold',
+ function () {
+ return 3;
+ }
+ );
+
+ $brands_admin = new WC_Brands_Admin();
+ ob_start();
+ $brands_admin->render_product_brand_filter();
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertStringContainsString(
+ '
factory()->term->create(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'name' => 'Blah_A',
+ )
+ );
+ $term_b_id = $this->factory()->term->create(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'name' => 'Foo_A',
+ )
+ );
+ $term_c_id = $this->factory()->term->create(
+ array(
+ 'taxonomy' => 'product_brand',
+ 'name' => 'Blah_B',
+ )
+ );
+
+ wp_set_post_terms( $simple_product->get_id(), array( $term_a_id, $term_b_id, $term_c_id ), 'product_brand' );
+
+ add_filter(
+ 'woocommerce_product_brand_filter_threshold',
+ function () {
+ return 2;
+ }
+ );
+
+ $brands_admin = new WC_Brands_Admin();
+ ob_start();
+ $brands_admin->render_product_brand_filter();
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertStringContainsString(
+ '