diff --git a/assets/css/admin/facebook-for-woocommerce-products-admin.css b/assets/css/admin/facebook-for-woocommerce-products-admin.css index 047f126e8..225156593 100644 --- a/assets/css/admin/facebook-for-woocommerce-products-admin.css +++ b/assets/css/admin/facebook-for-woocommerce-products-admin.css @@ -26,6 +26,9 @@ border-bottom: 1px solid #eee; } +#facebook_options .google_product_catgory { + border-top: 1px solid #eee; +} .woocommerce_variable_attributes .wp-editor-wrap label { float: left; @@ -165,4 +168,112 @@ .wp-editor-wrap { width: 100%; max-width: 100%; +} + +.facebook-metabox { + overflow: hidden; + clear: both; + border: 1px solid #ddd; + margin: 16px 0 !important; + background: #fff; + padding: 0 !important; /* Remove the previous padding */ + border-bottom: 1px solid #eee !important; +} +.facebook-metabox h3 { + margin: 0 !important; + font-size: 1em !important; + padding: 0.5em 0.75em 0.5em 1em !important; + cursor: pointer; + border-bottom: 1px solid #ddd; +} +.facebook-metabox.closed .handlediv:before { + content: "\f140" !important; +} +.facebook-metabox .handlediv:before { + content: "\f142" !important; + font: normal 20px/1 dashicons; +} +.facebook-metabox .wc-metabox-content { + padding: 1em; + background: #fff; +} +.facebook-metabox h3 strong { + line-height: 26px; + font-weight: 600; +} + +.sync-indicator .sync-tooltip { + display: none; + position: absolute; + background: #32373c; + padding: 8px; + border-radius: 3px; + color: #fff; + font-size: 11px; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; + line-height: 1.4; + white-space: nowrap; + z-index: 9999; + top: 100%; + left: 50%; + transform: translateX(-50%) translateY(8px); +} + +.sync-indicator .sync-tooltip:before { + content: ''; + position: absolute; + border: 6px solid transparent; + border-bottom-color: #32373c; + top: -12px; + left: 50%; + transform: translateX(-50%); +} + +.sync-indicator:hover .sync-tooltip { + display: block; +} + +.synced-attribute { + background-color: #f0f0f1 !important; + cursor: not-allowed; +} + +.sync-indicator .sync-tooltip:before { + content: ''; + position: absolute; + border: 6px solid transparent; + border-bottom-color: #32373c; + top: -12px; + left: 50%; + transform: translateX(-50%); +} + +.sync-indicator:hover .sync-tooltip { + display: block; +} + +.wc-attributes-icon { + display: inline-block; + margin-left: 8px; + cursor: help; + vertical-align: middle; + font-size: 14px; + position: relative; + line-height: 1; + top: 2px; +} + +.wc-attributes-icon:before { + font-family: Dashicons; + content: "\f175"; +} + +.woocommerce_options_panel { + input[type="text"], + input[type="number"], + input[type="email"], + input[type="url"], + select { + font-size: 12px !important; + } } \ No newline at end of file diff --git a/facebook-commerce.php b/facebook-commerce.php index 529d26041..83fc7f819 100644 --- a/facebook-commerce.php +++ b/facebook-commerce.php @@ -136,9 +136,9 @@ class WC_Facebookcommerce_Integration extends WC_Integration { // TODO probably some of these meta keys need to be moved to Facebook\Products {FN 2020-01-13}. - public const FB_PRODUCT_GROUP_ID = 'fb_product_group_id'; - public const FB_PRODUCT_ITEM_ID = 'fb_product_item_id'; - public const FB_PRODUCT_DESCRIPTION = 'fb_product_description'; + public const FB_PRODUCT_GROUP_ID = 'fb_product_group_id'; + public const FB_PRODUCT_ITEM_ID = 'fb_product_item_id'; + public const FB_PRODUCT_DESCRIPTION = 'fb_product_description'; public const FB_RICH_TEXT_DESCRIPTION = 'fb_rich_text_description'; /** @var string the API flag to set a product as visible in the Facebook shop */ public const FB_SHOP_PRODUCT_VISIBLE = 'published'; @@ -728,7 +728,7 @@ public function load_assets() { }, feed: { totalVisibleProducts: 'get_product_count() ); ?>', - hasClientSideFeedUpload: 'get_feed_id() ); ?>', + hasClientSideFeedUpload: 'get_feed_id() ); ?>', enabled: true, format: 'csv' }, @@ -820,20 +820,18 @@ public function on_product_save( int $wp_id ) { } $this->delete_fb_product( $delete_product ); } - } else { - if ( $sync_enabled ) { + } elseif ( $sync_enabled ) { Products::enable_sync_for_products( [ $product ] ); Products::set_product_visibility( $product, Admin::SYNC_MODE_SYNC_AND_HIDE !== $sync_mode ); $this->save_product_settings( $product ); - } else { - // if previously enabled, add a notice on the next page load - if ( Products::is_sync_enabled_for_product( $product ) ) { - Admin::add_product_disabled_sync_notice(); - } - Products::disable_sync_for_products( [ $product ] ); - if ( in_array( $wp_id, $products_to_delete_from_facebook, true ) ) { - $this->delete_fb_product( $product ); - } + } else { + // if previously enabled, add a notice on the next page load + if ( Products::is_sync_enabled_for_product( $product ) ) { + Admin::add_product_disabled_sync_notice(); + } + Products::disable_sync_for_products( [ $product ] ); + if ( in_array( $wp_id, $products_to_delete_from_facebook, true ) ) { + $this->delete_fb_product( $product ); } } if ( $sync_enabled ) { @@ -858,16 +856,58 @@ public function on_product_save( int $wp_id ) { } /** - * Saves the submitted Facebook settings for a variable product. + * Saves Facebook product attributes from POST data. * + * @param WC_Facebook_Product $woo_product The Facebook product object + */ + private function save_facebook_product_attributes( $woo_product ) { + // phpcs:disable WordPress.Security.NonceVerification.Missing + if ( isset( $_POST[ WC_Facebook_Product::FB_BRAND ] ) ) { + $woo_product->set_fb_brand( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_BRAND ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_MPN ] ) ) { + $woo_product->set_fb_mpn( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_MPN ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_SIZE ] ) ) { + $woo_product->set_fb_size( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_SIZE ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_COLOR ] ) ) { + $woo_product->set_fb_color( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_COLOR ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_MATERIAL ] ) ) { + $woo_product->set_fb_material( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_MATERIAL ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_PATTERN ] ) ) { + $woo_product->set_fb_pattern( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_PATTERN ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_AGE_GROUP ] ) ) { + $woo_product->set_fb_age_group( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_AGE_GROUP ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_GENDER ] ) ) { + $woo_product->set_fb_gender( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_GENDER ] ) ) ); + } + + if ( isset( $_POST[ WC_Facebook_Product::FB_PRODUCT_CONDITION ] ) ) { + $woo_product->set_fb_condition( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_PRODUCT_CONDITION ] ) ) ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + /** + * Saves the submitted Facebook settings for a variable product. * * @param \WC_Product $product The variable product object. */ - private function save_variable_product_settings( WC_Product $product ) { + private function save_variable_product_settings( $product ) { $woo_product = new WC_Facebook_Product( $product->get_id() ); - if ( isset( $_POST[ WC_Facebook_Product::FB_VARIABLE_BRAND ] ) ) { - $woo_product->set_fb_brand( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_VARIABLE_BRAND ] ) ) ); - } + $this->save_facebook_product_attributes( $woo_product ); } /** @@ -905,14 +945,7 @@ private function save_product_settings( WC_Product $product ) { $woo_product->set_product_video_urls( $attachment_ids ); } - if ( isset( $_POST[ WC_Facebook_Product::FB_BRAND ] ) ) { - $woo_product->set_fb_brand( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_BRAND ] ) ) ); - } - - if ( isset( $_POST[ WC_Facebook_Product::FB_MPN ] ) ) { - $woo_product->set_fb_mpn( sanitize_text_field( wp_unslash( $_POST[ WC_Facebook_Product::FB_MPN ] ) ) ); - } - // phpcs:enable WordPress.Security.NonceVerification.Missing + $this->save_facebook_product_attributes( $woo_product ); } /** @@ -937,7 +970,7 @@ public function on_product_delete( int $product_id ) { */ // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ( ! wp_doing_ajax() || ! isset( $_POST['action'] ) || 'ajax_delete_fb_product' !== $_POST['action'] ) - && ! Products::published_product_should_be_synced( $product ) && ! $product->is_type( 'variable' ) ) { + && ! Products::published_product_should_be_synced( $product ) && ! $product->is_type( 'variable' ) ) { return; } @@ -1092,7 +1125,6 @@ public function delete_draft_product( $post ) { } $this->on_product_delete( $post->ID ); - } @@ -1670,8 +1702,7 @@ public function delete_product_set_item( string $fb_product_set_id ) { * - product_item_id : if exists, means product was created else not and don't display * - should_sync: Don't display if the product is not supposed to be synced. * - * @param WP_Post $post Wordpress Post - * + * @param WP_Post $post WordPress Post * @return void */ public function display_batch_api_completed( $post ) { @@ -2281,7 +2312,7 @@ private function sync_facebook_products_using_background_processor() { ); $this->on_product_publish( $post_id ); - $count ++; + ++$count; } WC_Facebookcommerce_Utils::log( 'Synced ' . $count . ' products' ); $this->remove_sticky_message(); @@ -2736,9 +2767,9 @@ public function is_product_sync_enabled() { * implemented, which should work well for all stores. This option will not disable * the new improved implementation. * - * @return bool * @since 2.5.0 * + * @return bool */ public function is_legacy_feed_file_generation_enabled() { return 'yes' === get_option( self::OPTION_LEGACY_FEED_FILE_GENERATION_ENABLED, 'yes' ); @@ -2996,7 +3027,6 @@ public function update_fb_visibility( $product_id, $visibility ) { $fb_product_item_id = $this->get_product_fbid( self::FB_PRODUCT_ITEM_ID, $product->get_id() ); if ( ! $fb_product_item_id ) { \WC_Facebookcommerce_Utils::fblog( $fb_product_item_id . " doesn't exist but underwent a visibility transform.", [], true ); - return; } try { diff --git a/includes/Admin.php b/includes/Admin.php index 71a292c78..11a0049bf 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -72,6 +72,7 @@ public function __construct() { $this->product_sets = new Admin\Product_Sets(); // add a modal in admin product pages add_action( 'admin_footer', array( $this, 'render_modal_template' ) ); + add_action( 'admin_footer', array( $this, 'add_tab_switch_script' ) ); // add admin notice to inform that disabled products may need to be deleted manually add_action( 'admin_notices', array( $this, 'maybe_show_product_disabled_sync_notice' ) ); @@ -102,9 +103,11 @@ public function __construct() { // add Variation edit fields add_action( 'woocommerce_product_after_variable_attributes', array( $this, 'add_product_variation_edit_fields' ), 10, 3 ); add_action( 'woocommerce_save_product_variation', array( $this, 'save_product_variation_edit_fields' ), 10, 2 ); + add_action( 'wp_ajax_get_facebook_product_data', array( $this, 'ajax_get_facebook_product_data' ) ); // add custom taxonomy for Product Sets add_filter( 'gettext', array( $this, 'change_custom_taxonomy_tip' ), 20, 2 ); + add_action( 'wp_ajax_sync_facebook_attributes', array( $this, 'ajax_sync_facebook_attributes' ) ); } /** @@ -1242,14 +1245,20 @@ public function add_product_settings_tab_content() { $is_visible = $visibility ? wc_string_to_bool( $visibility ) : true; $product = wc_get_product( $post ); - $fb_product_description = get_post_meta( $post->ID, \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, true ); - $rich_text_description = get_post_meta( $post->ID, \WC_Facebookcommerce_Integration::FB_RICH_TEXT_DESCRIPTION, true ); - $price = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_PRICE, true ); - $image_source = get_post_meta( $post->ID, Products::PRODUCT_IMAGE_SOURCE_META_KEY, true ); - $image = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_IMAGE, true ); - $video_urls = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_VIDEO, true ); - $fb_brand = get_post_meta( $post->ID, \WC_Facebook_Product::FB_BRAND, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_BRAND, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_brand', true ); - $fb_mpn = get_post_meta( $post->ID, \WC_Facebook_Product::FB_MPN, true ); + $rich_text_description = get_post_meta( $post->ID, \WC_Facebookcommerce_Integration::FB_RICH_TEXT_DESCRIPTION, true ); + $price = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_PRICE, true ); + $image_source = get_post_meta( $post->ID, Products::PRODUCT_IMAGE_SOURCE_META_KEY, true ); + $image = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_IMAGE, true ); + $video_urls = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_VIDEO, true ); + $fb_brand = get_post_meta( $post->ID, \WC_Facebook_Product::FB_BRAND, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_BRAND, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_brand', true ); + $fb_mpn = get_post_meta( $post->ID, \WC_Facebook_Product::FB_MPN, true ); + $fb_condition = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PRODUCT_CONDITION, true ); + $fb_age_group = get_post_meta( $post->ID, \WC_Facebook_Product::FB_AGE_GROUP, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_AGE_GROUP, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_age_group', true ); + $fb_gender = get_post_meta( $post->ID, \WC_Facebook_Product::FB_GENDER, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_GENDER, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_gender', true ); + $fb_size = get_post_meta( $post->ID, \WC_Facebook_Product::FB_SIZE, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_SIZE, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_size', true ); + $fb_color = get_post_meta( $post->ID, \WC_Facebook_Product::FB_COLOR, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_COLOR, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_color', true ); + $fb_material = get_post_meta( $post->ID, \WC_Facebook_Product::FB_MATERIAL, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_MATERIAL, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_material', true ); + $fb_pattern = get_post_meta( $post->ID, \WC_Facebook_Product::FB_PATTERN, true ) ? get_post_meta( $post->ID, \WC_Facebook_Product::FB_PATTERN, true ) : get_post_meta( $post->ID, '_wc_facebook_enhanced_catalog_attributes_pattern', true ); if ( $sync_enabled ) { $sync_mode = $is_visible ? self::SYNC_MODE_SYNC_AND_SHOW : self::SYNC_MODE_SYNC_AND_HIDE; @@ -1262,6 +1271,7 @@ public function add_product_settings_tab_content() {
'wc_facebook_sync_mode', @@ -1279,8 +1289,8 @@ public function add_product_settings_tab_content() { echo '
'; echo ''; + esc_html__( 'Facebook Description', 'facebook-for-woocommerce' ) . + ''; wp_editor( $rich_text_description, \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, @@ -1344,48 +1354,174 @@ public function add_product_settings_tab_content() { ) ); + woocommerce_wp_hidden_input( + array( + 'id' => \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, + 'value' => '', + ) + ); + ?> +
+ +
+

+ + + + +

+
+ + + + \WC_Facebook_Product::FB_BRAND, - 'label' => __( 'Brand', 'facebook-for-woocommerce' ), - 'value' => $fb_brand, - 'class' => 'enable-if-sync-enabled', + 'id' => \WC_Facebook_Product::FB_MPN, + 'name' => \WC_Facebook_Product::FB_MPN, + 'label' => __( 'Manufacturer Part Number (MPN)', 'facebook-for-woocommerce' ), + 'value' => $fb_mpn, + 'class' => 'enable-if-sync-enabled', + 'desc_tip' => true, ) ); woocommerce_wp_text_input( array( - 'id' => \WC_Facebook_Product::FB_MPN, - 'label' => __( 'Manufacturer Parts Number (MPN)', 'facebook-for-woocommerce' ), - 'value' => $fb_mpn, - 'class' => 'enable-if-sync-enabled', + 'id' => \WC_Facebook_Product::FB_BRAND, + 'name' => \WC_Facebook_Product::FB_BRAND, + 'label' => __( 'Brand', 'facebook-for-woocommerce' ), + 'value' => $fb_brand, + 'class' => 'enable-if-sync-enabled', + 'desc_tip' => true, + 'description' => __( 'Brand name of the item', 'facebook-for-woocommerce' ), ) ); - woocommerce_wp_hidden_input( + woocommerce_wp_select( array( - 'id' => \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, - 'value' => '', + 'id' => \WC_Facebook_Product::FB_PRODUCT_CONDITION, + 'name' => \WC_Facebook_Product::FB_PRODUCT_CONDITION, + 'label' => __( 'Condition', 'facebook-for-woocommerce' ), + 'options' => array( + '' => __( 'Select', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::CONDITION_NEW => __( 'New', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::CONDITION_REFURBISHED => __( 'Refurbished', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::CONDITION_USED => __( 'Used', 'facebook-for-woocommerce' ), + ), + 'value' => $fb_condition, + 'desc_tip' => true, + 'description' => __( 'This refers to the condition of your product. Supported values are new, refurbished and used.', 'facebook-for-woocommerce' ), ) ); - ?> -
- -
- \WC_Facebook_Product::FB_VARIABLE_BRAND, - 'label' => __( 'Brand', 'facebook-for-woocommerce' ), - 'value' => $fb_brand, - 'class' => 'enable-if-sync-enabled', - ) - ); - ?> -
- -
+ woocommerce_wp_text_input( + array( + 'id' => \WC_Facebook_Product::FB_SIZE, + 'label' => __( 'Size', 'facebook-for-woocommerce' ), + 'desc_tip' => true, + 'description' => __( 'Size of the product item', 'facebook-for-woocommerce' ), + 'name' => \WC_Facebook_Product::FB_SIZE, + 'cols' => 40, + 'rows' => 60, + 'value' => $fb_size, + 'class' => 'enable-if-sync-enabled', + ) + ); + + woocommerce_wp_text_input( + array( + 'id' => \WC_Facebook_Product::FB_COLOR, + 'name' => \WC_Facebook_Product::FB_COLOR, + 'label' => __( 'Color', 'facebook-for-woocommerce' ), + 'desc_tip' => true, + 'description' => __( 'Color of the product item', 'facebook-for-woocommerce' ), + 'cols' => 40, + 'rows' => 60, + 'value' => $fb_color, + 'class' => 'enable-if-sync-enabled', + ) + ); + + woocommerce_wp_select( + array( + 'id' => \WC_Facebook_Product::FB_AGE_GROUP, + 'name' => \WC_Facebook_Product::FB_AGE_GROUP, + 'label' => __( 'Age Group', 'facebook-for-woocommerce' ), + 'options' => array( + '' => __( 'Select', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_ADULT => __( 'Adult', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_ALL_AGES => __( 'All Ages', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_TEEN => __( 'Teen', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_KIDS => __( 'Kids', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_TODDLER => __( 'Toddler', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_INFANT => __( 'Infant', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::AGE_GROUP_NEWBORN => __( 'Newborn', 'facebook-for-woocommerce' ), + ), + 'value' => $fb_age_group, + 'desc_tip' => true, + 'description' => __( 'Select the age group for this product.', 'facebook-for-woocommerce' ), + ) + ); + + woocommerce_wp_select( + array( + 'id' => \WC_Facebook_Product::FB_GENDER, + 'name' => \WC_Facebook_Product::FB_GENDER, + 'label' => __( 'Gender', 'facebook-for-woocommerce' ), + 'options' => array( + '' => __( 'Select', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::GENDER_FEMALE => __( 'Female', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::GENDER_MALE => __( 'Male', 'facebook-for-woocommerce' ), + \WC_Facebook_Product::GENDER_UNISEX => __( 'Unisex', 'facebook-for-woocommerce' ), + ), + 'value' => $fb_gender, + 'desc_tip' => true, + 'description' => __( 'Select the gender for this product.', 'facebook-for-woocommerce' ), + ) + ); + + woocommerce_wp_text_input( + array( + 'id' => \WC_Facebook_Product::FB_MATERIAL, + 'label' => __( 'Material', 'facebook-for-woocommerce' ), + 'desc_tip' => true, + 'description' => __( 'Material of the product item', 'facebook-for-woocommerce' ), + 'name' => \WC_Facebook_Product::FB_MATERIAL, + 'cols' => 40, + 'rows' => 60, + 'value' => $fb_material, + 'class' => 'enable-if-sync-enabled', + ) + ); + + woocommerce_wp_text_input( + array( + 'id' => \WC_Facebook_Product::FB_PATTERN, + 'label' => __( 'Pattern', 'facebook-for-woocommerce' ), + 'desc_tip' => true, + 'description' => __( 'Pattern of the product item', 'facebook-for-woocommerce' ), + 'name' => \WC_Facebook_Product::FB_PATTERN, + 'cols' => 40, + 'rows' => 60, + 'value' => $fb_pattern, + 'class' => 'enable-if-sync-enabled', + ) + ); + + ?> + +
@@ -1405,7 +1541,6 @@ public function add_product_settings_tab_content() { * @param \WC_Post $post the post type for the current variation */ public function add_product_variation_edit_fields( $index, $variation_data, $post ) { - $variation = wc_get_product( $post ); if ( ! $variation instanceof \WC_Product_Variation ) { @@ -1418,6 +1553,7 @@ public function add_product_variation_edit_fields( $index, $variation_data, $pos return; } + // Get variation meta values $sync_enabled = 'no' !== $this->get_product_variation_meta( $variation, Products::SYNC_ENABLED_META_KEY, $parent ); $visibility = $this->get_product_variation_meta( $variation, Products::VISIBILITY_META_KEY, $parent ); $is_visible = $visibility ? wc_string_to_bool( $visibility ) : true; @@ -1433,98 +1569,132 @@ public function add_product_variation_edit_fields( $index, $variation_data, $pos $sync_mode = self::SYNC_MODE_SYNC_DISABLED; } - woocommerce_wp_select( - array( - 'id' => "variable_facebook_sync_mode$index", - 'name' => "variable_facebook_sync_mode[$index]", - 'label' => __( 'Facebook Sync', 'facebook-for-woocommerce' ), - 'options' => array( - self::SYNC_MODE_SYNC_AND_SHOW => __( 'Sync and show in catalog', 'facebook-for-woocommerce' ), - self::SYNC_MODE_SYNC_AND_HIDE => __( 'Sync and hide in catalog', 'facebook-for-woocommerce' ), - self::SYNC_MODE_SYNC_DISABLED => __( 'Do not sync', 'facebook-for-woocommerce' ), - ), - 'value' => $sync_mode, - 'desc_tip' => true, - 'description' => __( 'Choose whether to sync this product to Facebook and, if synced, whether it should be visible in the catalog.', 'facebook-for-woocommerce' ), - 'class' => 'js-variable-fb-sync-toggle', - 'wrapper_class' => 'form-row form-row-full', - ) - ); + ?> +
+

+ +
+

+ +
+ + + + + get_attributes(); + $facebook_fields = []; + + $attribute_map = [ + 'material' => \WC_Facebook_Product::FB_MATERIAL, + 'color' => \WC_Facebook_Product::FB_COLOR, + 'colour' => \WC_Facebook_Product::FB_COLOR, // Add support for British spelling + 'size' => \WC_Facebook_Product::FB_SIZE, + 'pattern' => \WC_Facebook_Product::FB_PATTERN, + 'brand' => \WC_Facebook_Product::FB_BRAND, + 'mpn' => \WC_Facebook_Product::FB_MPN, + ]; + + // Then process existing attributes + foreach ( $attributes as $attribute ) { + $normalized_attr_name = strtolower( $attribute->get_name() ); + + // Special handling for color/colour + if ( 'color' === $normalized_attr_name || 'colour' === $normalized_attr_name ) { + $meta_key = \WC_Facebook_Product::FB_COLOR; + $field_name = 'color'; + } else { + $meta_key = $attribute_map[ $normalized_attr_name ] ?? null; + $field_name = $normalized_attr_name; + } + + if ( $meta_key ) { + $values = []; + + if ( $attribute->is_taxonomy() ) { + $terms = $attribute->get_terms(); + if ( $terms ) { + $values = wp_list_pluck( $terms, 'name' ); + } + } else { + $values = $attribute->get_options(); + } + + if ( ! empty( $values ) ) { + // Join multiple values with a pipe character and spaces + $joined_values = implode( ' | ', $values ); + $facebook_fields[ $field_name ] = $joined_values; + update_post_meta( $product_id, $meta_key, $joined_values ); + } else { + delete_post_meta( $product_id, $meta_key ); + $facebook_fields[ $field_name ] = ''; + } + } + } + + return $facebook_fields; + } + + public function ajax_sync_facebook_attributes() { + check_ajax_referer( 'sync_facebook_attributes', 'nonce' ); + + $product_id = isset( $_POST['product_id'] ) ? intval( $_POST['product_id'] ) : 0; + if ( $product_id ) { + $synced_fields = $this->sync_product_attributes( $product_id ); + wp_send_json_success( $synced_fields ); + } + wp_send_json_error( 'Invalid product ID' ); + } } diff --git a/includes/Admin/Enhanced_Catalog_Attribute_Fields.php b/includes/Admin/Enhanced_Catalog_Attribute_Fields.php index d1662638f..59454bf65 100644 --- a/includes/Admin/Enhanced_Catalog_Attribute_Fields.php +++ b/includes/Admin/Enhanced_Catalog_Attribute_Fields.php @@ -85,22 +85,23 @@ private function extract_attribute( &$attributes, $key ) { $extracted = false === $index ? array() : array_splice( $attributes, $index, 1 ); return empty( $extracted ) ? null : array_shift( $extracted ); } - public function render( $category_id ) { - $all_attributes = $this->category_handler->get_attributes_with_fallback_to_parent_category( $category_id ); + $all_attributes = (array) $this->category_handler->get_attributes_with_fallback_to_parent_category( $category_id ); + $all_attributes_with_values = array_map( function ( $attribute ) use ( $category_id ) { return array_merge( $attribute, array( 'value' => $this->get_value( $attribute['key'], $category_id ) ) ); }, $all_attributes ); - $recommended_attributes = array_filter( + + $recommended_attributes = array_filter( $all_attributes_with_values, function ( $attr ) { return $attr['recommended']; } ); - $optional_attributes = array_filter( + $optional_attributes = array_filter( $all_attributes_with_values, function ( $attr ) { return ! $attr['recommended']; @@ -142,15 +143,20 @@ function ( $attr ) { $priority[ $key ] = $recommended_attributes[ $key ]['priority']; } + $should_render_checkbox = ! empty( $recommended_attributes ); + array_multisort( $priority, SORT_DESC, $recommended_attributes ); + $selector_value = $this->get_value( self::OPTIONAL_SELECTOR_KEY, $category_id ); + $is_showing_optional = 'on' === $selector_value; - foreach ( $recommended_attributes as $attribute ) { - $this->render_attribute( $attribute ); + // Only show the selector if we have natural recommendations + if ( $should_render_checkbox ) { + $this->render_selector_checkbox( $is_showing_optional ); } - $selector_value = $this->get_value( self::OPTIONAL_SELECTOR_KEY, $category_id ); - $is_showing_optional = 'on' === $selector_value; - $this->render_selector_checkbox( $is_showing_optional ); + foreach ( $recommended_attributes as $attribute ) { + $this->render_attribute( $attribute, true, $is_showing_optional ); + } foreach ( $optional_attributes as $attribute ) { $this->render_attribute( $attribute, true, $is_showing_optional ); @@ -159,7 +165,7 @@ function ( $attr ) { private function render_selector_checkbox( $is_showing_optional ) { $selector_id = self::FIELD_ENHANCED_CATALOG_ATTRIBUTE_PREFIX . self::OPTIONAL_SELECTOR_KEY; - $selector_label = __( 'Show advanced options', 'facebook-for-woocommerce' ); + $selector_label = __( 'Show more attributes', 'facebook-for-woocommerce' ); $checked_attr = $is_showing_optional ? 'checked="checked"' : ''; if ( self::PAGE_TYPE_EDIT_PRODUCT === $this->page_type ) { @@ -291,4 +297,4 @@ private function render_text_field( $attr_id, $attribute, $placeholder ) {
- +

@@ -150,12 +150,12 @@ public function render_add_google_product_category_field() { } /** - * Returns the text that explains why the categories are being displayed + * Returns the text that explains why certain fields should be inputed for ad performance * * @return string the explanation text */ - public static function get_enhanced_catalog_explanation_text() { - return __( 'Facebook catalogs now support category specific fields, to make best use of them you need to select a category. WooCommerce uses the Google taxonomy as it is the most widely accepted form of categorisation. If no Google product category is chosen, the WooCommerce product category will be used instead.', 'facebook-for-woocommerce' ); + public static function get_catalog_explanation_text() { + return __( 'To optimize ad performance, we recommend providing these additional product attributes in WooCommerce. Updates made here will be overwritten with attributes provided in WooCommerce.', 'facebook-for-woocommerce' ); } @@ -174,7 +174,7 @@ public function render_edit_google_product_category_field( \WP_Term $term ) { ?> - + diff --git a/includes/Admin/Products.php b/includes/Admin/Products.php index 437e56140..050fb2e7b 100644 --- a/includes/Admin/Products.php +++ b/includes/Admin/Products.php @@ -43,9 +43,6 @@ class Products { public static function render_google_product_category_fields_and_enhanced_attributes( \WC_Product $product ) { ?>
-

- -

render( $category_id ); ?> - - true ]; + /** + * List of keys to exclude from general attribute processing. + * These are special attributes handled separately by Facebook catalog. + * + * @var array $keys_to_exclude Associative array of attribute keys to exclude + * Keys include: brand, color/colour, material, gender, + * condition, size, age_group, and pattern + */ + private $keys_to_exclude = [ + 'brand' => true, + 'color' => true, + 'material' => true, + 'gender' => true, + 'condition' => true, + 'size' => true, + 'colour' => true, + 'age_group' => true, + 'pattern' => true, + ]; /** * Fetches the attribute from a category using attribute key. diff --git a/includes/fbproduct.php b/includes/fbproduct.php index 12a3b41b5..b9b0f5e12 100644 --- a/includes/fbproduct.php +++ b/includes/fbproduct.php @@ -21,6 +21,53 @@ * Custom FB Product proxy class */ class WC_Facebook_Product { + + + /** + * Product-related constants used for form rendering. + * These constants are used in the admin interface for product settings forms. + * The actual product data handling uses the same constants defined in WC_Facebook_Product. + */ + + /** @var string the "new" condition */ + const CONDITION_NEW = 'new'; + + /** @var string the "used" condition */ + const CONDITION_USED = 'used'; + + /** @var string the "refurbished" condition */ + const CONDITION_REFURBISHED = 'refurbished'; + + /** @var string the "adult" age group */ + const AGE_GROUP_ADULT = 'adult'; + + /** @var string the "all ages" age group */ + const AGE_GROUP_ALL_AGES = 'all ages'; + + /** @var string the "teen" age group */ + const AGE_GROUP_TEEN = 'teen'; + + /** @var string the "kids" age group */ + const AGE_GROUP_KIDS = 'kids'; + + /** @var string the "toddler" age group */ + const AGE_GROUP_TODDLER = 'toddler'; + + /** @var string the "infant" age group */ + const AGE_GROUP_INFANT = 'infant'; + + /** @var string the "newborn" age group */ + const AGE_GROUP_NEWBORN = 'newborn'; + + /** @var string the "male" gender */ + const GENDER_MALE = 'male'; + + /** @var string the "female" gender */ + const GENDER_FEMALE = 'female'; + + /** @var string the "unisex" gender */ + const GENDER_UNISEX = 'unisex'; + // Used for the background sync const PRODUCT_PREP_TYPE_ITEMS_BATCH = 'items_batch'; // Used for the background feed upload @@ -32,15 +79,22 @@ class WC_Facebook_Product { // to this object. const FB_PRODUCT_DESCRIPTION = 'fb_product_description'; const FB_PRODUCT_PRICE = 'fb_product_price'; + const FB_SIZE = 'fb_size'; + const FB_COLOR = 'fb_color'; + const FB_MATERIAL = 'fb_material'; + const FB_PATTERN = 'fb_pattern'; const FB_PRODUCT_IMAGE = 'fb_product_image'; - const FB_PRODUCT_VIDEO = 'fb_product_video'; - const FB_VARIANT_IMAGE = 'fb_image'; - const FB_VISIBILITY = 'fb_visibility'; - const FB_REMOVE_FROM_SYNC = 'fb_remove_from_sync'; + const FB_PRODUCT_CONDITION = 'fb_product_condition'; + const FB_AGE_GROUP = 'fb_age_group'; + const FB_GENDER = 'fb_gender'; + const FB_PRODUCT_VIDEO = 'fb_product_video'; + const FB_VARIANT_IMAGE = 'fb_image'; + const FB_VISIBILITY = 'fb_visibility'; + const FB_REMOVE_FROM_SYNC = 'fb_remove_from_sync'; const FB_RICH_TEXT_DESCRIPTION = 'fb_rich_text_description'; - const FB_BRAND = 'fb_brand'; - const FB_VARIABLE_BRAND = 'fb_variable_brand'; - const FB_MPN = 'fb_mpn'; + const FB_BRAND = 'fb_brand'; + const FB_VARIABLE_BRAND = 'fb_variable_brand'; + const FB_MPN = 'fb_mpn'; const MIN_DATE_1 = '1970-01-29'; const MIN_DATE_2 = '1970-01-30'; @@ -126,9 +180,9 @@ public function __construct( $wpid, $parent_product = null ) { // Variable products should use some data from the parent_product // For performance reasons, that data shouldn't be regenerated every time. if ( $parent_product ) { - $this->gallery_urls = $parent_product->get_gallery_urls(); - $this->fb_use_parent_image = $parent_product->get_use_parent_image(); - $this->main_description = $parent_product->get_fb_description(); + $this->gallery_urls = $parent_product->get_gallery_urls(); + $this->fb_use_parent_image = $parent_product->get_use_parent_image(); + $this->main_description = $parent_product->get_fb_description(); $this->rich_text_description = $parent_product->get_rich_text_description(); } } @@ -300,33 +354,33 @@ public function get_all_video_urls() { $video_urls = array(); $attached_videos = get_attached_media( 'video', $this->id ); - - $custom_video_urls = $this->woo_product->get_meta( self::FB_PRODUCT_VIDEO ); - - if ( empty( $attached_videos ) && empty( $custom_video_urls ) ) { - return $video_urls; - } - - // Add custom video URLs to the list - if (!empty($custom_video_urls) && is_array($custom_video_urls)) { - foreach ($custom_video_urls as $custom_url) { - $custom_url = trim($custom_url); - if (!empty($custom_url)) { - $video_urls[] = array('url' => $custom_url); - } - } - } - - // Add attached video URLs to the list, excluding duplicates from custom video URLs - if (!empty($attached_videos)) { - $custom_video_url_set = array_flip(array_column($video_urls, 'url')); - foreach ($attached_videos as $video) { - $url = wp_get_attachment_url($video->ID); - if ($url && !isset($custom_video_url_set[$url])) { - $video_urls[] = array('url' => $url); - } - } - } + + $custom_video_urls = $this->woo_product->get_meta( self::FB_PRODUCT_VIDEO ); + + if ( empty( $attached_videos ) && empty( $custom_video_urls ) ) { + return $video_urls; + } + + // Add custom video URLs to the list + if ( ! empty( $custom_video_urls ) && is_array( $custom_video_urls ) ) { + foreach ( $custom_video_urls as $custom_url ) { + $custom_url = trim( $custom_url ); + if ( ! empty( $custom_url ) ) { + $video_urls[] = array( 'url' => $custom_url ); + } + } + } + + // Add attached video URLs to the list, excluding duplicates from custom video URLs + if ( ! empty( $attached_videos ) ) { + $custom_video_url_set = array_flip( array_column( $video_urls, 'url' ) ); + foreach ( $attached_videos as $video ) { + $url = wp_get_attachment_url( $video->ID ); + if ( $url && ! isset( $custom_video_url_set[ $url ] ) ) { + $video_urls[] = array( 'url' => $url ); + } + } + } return $video_urls; } @@ -380,16 +434,21 @@ public function set_product_image( $image ) { ); } } - + public function set_product_video_urls( $attachment_ids ) { - $video_urls = array_filter(array_map(function($id) { - return trim(wp_get_attachment_url($id)); - }, explode(',', $attachment_ids))); - update_post_meta( - $this->id, - self::FB_PRODUCT_VIDEO, - $video_urls - ); + $video_urls = array_filter( + array_map( + function ( $id ) { + return trim( wp_get_attachment_url( $id ) ); + }, + explode( ',', $attachment_ids ) + ) + ); + update_post_meta( + $this->id, + self::FB_PRODUCT_VIDEO, + $video_urls + ); } public function set_rich_text_description( $rich_text_description ) { @@ -414,16 +473,55 @@ public function set_fb_brand( $fb_brand ) { ); } - public function set_fb_mpn( $fb_mpn ) { - $fb_mpn = stripslashes( - WC_Facebookcommerce_Utils::clean_string( $fb_mpn ) + /** + * Utility method to set basic Facebook product attributes + * + * @param string $key The meta key to store the value under + * @param string $value The value to store + * @return void + */ + private function set_fb_attribute($key, $value) { + $value = stripslashes( + WC_Facebookcommerce_Utils::clean_string($value) ); update_post_meta( $this->id, - self::FB_MPN, - $fb_mpn + $key, + $value ); } + + public function set_fb_material( $fb_material ) { + $this->set_fb_attribute(self::FB_MATERIAL, $fb_material); + } + + public function set_fb_pattern( $fb_pattern ) { + $this->set_fb_attribute(self::FB_PATTERN, $fb_pattern); + } + + public function set_fb_mpn( $fb_mpn ) { + $this->set_fb_attribute(self::FB_MPN, $fb_mpn); + } + + public function set_fb_condition( $fb_condition ) { + $this->set_fb_attribute(self::FB_PRODUCT_CONDITION, $fb_condition); + } + + public function set_fb_age_group( $fb_age_group ) { + $this->set_fb_attribute(self::FB_AGE_GROUP, $fb_age_group); + } + + public function set_fb_gender( $fb_gender ) { + $this->set_fb_attribute(self::FB_GENDER, $fb_gender); + } + + public function set_fb_color( $fb_color ) { + $this->set_fb_attribute(self::FB_COLOR, $fb_color); + } + + public function set_fb_size( $fb_size ) { + $this->set_fb_attribute(self::FB_SIZE, $fb_size); + } public function set_price( $price ) { if ( is_numeric( $price ) ) { @@ -459,35 +557,46 @@ public function set_use_parent_image( $setting ) { } public function get_fb_brand() { - // Get brand directly from post meta - $fb_brand = get_post_meta( - $this->id, - self::FB_BRAND, - true - ); + // If this is a variation, first check for variation-specific brand + if ($this->is_type('variation')) { + // Get brand directly from variation's post meta + $fb_brand = get_post_meta( + $this->id, + self::FB_BRAND, + true + ); - // If empty and this is a variation, get the parent brand - if ( empty( $fb_brand ) && $this->is_type('variation') ) { - $parent_id = $this->get_parent_id(); - if ( $parent_id ) { - $fb_brand = get_post_meta($parent_id, self::FB_BRAND, true); + // If variation has no brand set, get from parent + if (empty($fb_brand)) { + $parent_id = $this->get_parent_id(); + if ($parent_id) { + $fb_brand = get_post_meta($parent_id, self::FB_BRAND, true); + } } + } else { + // Get brand directly from post meta for non-variation products + $fb_brand = get_post_meta( + $this->id, + self::FB_BRAND, + true + ); } - // Fallback to brand attribute or store name if no brand found - if ( empty( $fb_brand ) ) { - $brand = get_post_meta( $this->id, Products::ENHANCED_CATALOG_ATTRIBUTES_META_KEY_PREFIX . 'brand', true ); - $brand_taxonomy = get_the_term_list( $this->id, 'product_brand', '', ', ' ); - if ( $brand ) { + // Only fallback to store name if no brand is found on product or parent + if (empty($fb_brand)) { + $brand = get_post_meta($this->id, Products::ENHANCED_CATALOG_ATTRIBUTES_META_KEY_PREFIX . 'brand', true); + $brand_taxonomy = get_the_term_list($this->id, 'product_brand', '', ', '); + + if ($brand) { $fb_brand = $brand; - } elseif ( !is_wp_error( $brand_taxonomy ) && $brand_taxonomy ) { + } elseif (!is_wp_error($brand_taxonomy) && $brand_taxonomy) { $fb_brand = $brand_taxonomy; } else { - $fb_brand = wp_strip_all_tags( WC_Facebookcommerce_Utils::get_store_name() ); + $fb_brand = wp_strip_all_tags(WC_Facebookcommerce_Utils::get_store_name()); } } - return WC_Facebookcommerce_Utils::clean_string( $fb_brand ); + return WC_Facebookcommerce_Utils::clean_string($fb_brand); } public function get_fb_description() { @@ -614,10 +723,10 @@ public function get_rich_text_description() { */ public function add_sale_price( $product_data, $for_items_batch = false ) { - $sale_price = $this->woo_product->get_sale_price(); + $sale_price = $this->woo_product->get_sale_price(); $sale_price_effective_date = ''; - $sale_start = ''; - $sale_end = ''; + $sale_start = ''; + $sale_end = ''; // check if sale exist if ( is_numeric( $sale_price ) && $sale_price > 0 ) { @@ -633,7 +742,7 @@ public function add_sale_price( $product_data, $for_items_batch = false ) { ( $sale_start == self::MIN_DATE_1 . self::MIN_TIME && $sale_end == self::MAX_DATE . self::MAX_TIME ) ? '' : $sale_start . '/' . $sale_end; - $sale_price = + $sale_price = intval( round( $this->get_price_plus_tax( $sale_price ) * 100 ) ); // Set Sale start and end as empty if set to default values @@ -656,24 +765,6 @@ public function add_sale_price( $product_data, $for_items_batch = false ) { return $product_data; } - public function get_fb_mpn() { - $fb_mpn = get_post_meta( - $this->id, - self::FB_MPN, - true - ); - - // If empty and this is a variation, get the parent MPN - if ( empty( $fb_mpn ) && $this->is_type('variation') ) { - $parent_id = $this->get_parent_id(); - if ( $parent_id ) { - $fb_mpn = get_post_meta($parent_id, self::FB_MPN, true); - } - } - - return WC_Facebookcommerce_Utils::clean_string( $fb_mpn ); - } - public function get_price_plus_tax( $price ) { $woo_product = $this->woo_product; // // wc_get_price_including_tax exist for Woo > 2.7 @@ -709,6 +800,260 @@ function ( $slug_name ) use ( $terms ) { ); } + public function get_fb_condition() { + // Get condition directly from post meta + $fb_condition = get_post_meta( + $this->id, + self::FB_PRODUCT_CONDITION, + true + ); + + // If empty and this is a variation, get the parent condition + if ( empty( $fb_condition ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_condition = get_post_meta( $parent_id, self::FB_PRODUCT_CONDITION, true ); + } + } + + return WC_Facebookcommerce_Utils::clean_string( $fb_condition ) ?: self::CONDITION_NEW; + } + + + public function get_fb_age_group() { + // If this is a variation, get its specific age group value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'age_group') { + return WC_Facebookcommerce_Utils::clean_string($value); + } + } + } + + // Get age group directly from post meta + $fb_age_group = get_post_meta( + $this->id, + self::FB_AGE_GROUP, + true + ); + + // If empty and this is a variation, get the parent age group + if ( empty( $fb_age_group ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_age_group = get_post_meta( $parent_id, self::FB_AGE_GROUP, true ); + } + } + + return WC_Facebookcommerce_Utils::clean_string( $fb_age_group ); + } + + public function get_fb_gender() { + // If this is a variation, get its specific gender value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'gender') { + return WC_Facebookcommerce_Utils::clean_string($value); + } + } + } + + // Get gender directly from post meta + $fb_gender = get_post_meta( + $this->id, + self::FB_GENDER, + true + ); + + // If empty and this is a variation, get the parent condition + if ( empty( $fb_gender ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_gender = get_post_meta( $parent_id, self::FB_GENDER, true ); + } + } + + return WC_Facebookcommerce_Utils::clean_string( $fb_gender ); + } + + + /** + * Gets the FB size value for the product. + * + * @return string + */ + public function get_fb_size() { + // If this is a variation, get its specific size value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'size') { + return mb_substr(WC_Facebookcommerce_Utils::clean_string($value), 0, 200); + } + } + } + + // Get size directly from post meta + $fb_size = get_post_meta( + $this->id, + self::FB_SIZE, + true + ); + + // If empty and this is a variation, get the parent condition + if ( empty( $fb_size ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_size = get_post_meta( $parent_id, self::FB_SIZE, true ); + } + } + + return mb_substr( WC_Facebookcommerce_Utils::clean_string( $fb_size ), 0, 200 ); + } + + + /** + * Gets the FB color value for the product. + * + * @return string + */ + public function get_fb_color() { + // If this is a variation, get its specific color value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'color' || $attr_key === 'colour') { + return mb_substr(WC_Facebookcommerce_Utils::clean_string($value), 0, 200); + } + } + } + + // Get color directly from post meta for non-variation products + $fb_color = get_post_meta( + $this->id, + self::FB_COLOR, + true + ); + + // If empty and this is a variation, get the parent color + if ( empty( $fb_color ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_color = get_post_meta( $parent_id, self::FB_COLOR, true ); + } + } + + return mb_substr(WC_Facebookcommerce_Utils::clean_string($fb_color), 0, 200); + } + + /** + * Gets the FB material value for the product. + * + * @return string + */ + public function get_fb_material() { + // If this is a variation, get its specific material value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + // Check for material attribute + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'material') { + return mb_substr(WC_Facebookcommerce_Utils::clean_string($value), 0, 200); + } + } + } + + // Get material directly from post meta for non-variation products + $fb_material = get_post_meta( + $this->id, + self::FB_MATERIAL, + true + ); + + return mb_substr(WC_Facebookcommerce_Utils::clean_string($fb_material), 0, 200); + } + + public function get_fb_mpn() { + // If this is a variation, get its specific mpn value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + // Check for mpn attribute + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'mpn') { + return mb_substr(WC_Facebookcommerce_Utils::clean_string($value), 0, 200); + } + } + } + + // Get material directly from post meta for non-variation products + $fb_mpn = get_post_meta( + $this->id, + self::FB_MPN, + true + ); + + // If empty and this is a variation, get the parent mpn + if ( empty( $fb_mpn ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_mpn = get_post_meta( $parent_id, self::FB_MPN, true ); + } + } + + return WC_Facebookcommerce_Utils::clean_string( $fb_mpn ); + } + + /** + * Gets the FB pattern value for the product. + * + * @return string + */ + public function get_fb_pattern() { + // If this is a variation, get its specific material value + if ($this->is_type('variation')) { + $attributes = $this->woo_product->get_attributes(); + + // Check for material attribute + foreach ($attributes as $key => $value) { + $attr_key = strtolower($key); + if ($attr_key === 'pattern') { + return mb_substr(WC_Facebookcommerce_Utils::clean_string($value), 0, 200); + } + } + } + + // Get pattern directly from post meta + $fb_pattern = get_post_meta( + $this->id, + self::FB_PATTERN, + true + ); + + // If empty and this is a variation, get the parent pattern + if ( empty( $fb_pattern ) && $this->is_type( 'variation' ) ) { + $parent_id = $this->get_parent_id(); + if ( $parent_id ) { + $fb_pattern = get_post_meta( $parent_id, self::FB_PATTERN, true ); + } + } + + return mb_substr( WC_Facebookcommerce_Utils::clean_string( $fb_pattern ), 0, 200 ); + } + public function update_visibility( $is_product_page, $visible_box_checked ) { $visibility = get_post_meta( $this->id, self::FB_VISIBILITY, true ); @@ -833,6 +1178,15 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel $product_data[ 'visibility' ] = Products::is_product_visible( $this->woo_product ) ? \WC_Facebookcommerce_Integration::FB_SHOP_PRODUCT_VISIBLE : \WC_Facebookcommerce_Integration::FB_SHOP_PRODUCT_HIDDEN; $product_data[ 'retailer_id' ] = $retailer_id; $product_data[ 'external_variant_id' ] = $this->get_id(); + $product_data[ 'condition' ] = $this->get_fb_condition(); + $product_data[ 'size' ] = $this->get_fb_size(); + $product_data[ 'color' ] = $this->get_fb_color(); + $product_data[ 'mpn' ] = $this->get_fb_mpn(); + $product_data[ 'pattern' ] = Helper::str_truncate( $this->get_fb_pattern(), 100 ); + $product_data[ 'age_group' ] = $this->get_fb_age_group(); + $product_data[ 'gender' ] = $this->get_fb_gender(); + $product_data[ 'material' ] = Helper::str_truncate( $this->get_fb_material(), 100 ); + $product_data[ 'pattern' ] = Helper::str_truncate( $this->get_fb_pattern(), 100 ); if ( self::PRODUCT_PREP_TYPE_ITEMS_BATCH === $type_to_prepare_for ) { $product_data['title'] = WC_Facebookcommerce_Utils::clean_string( $this->get_title() ); @@ -872,7 +1226,7 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel $google_product_category = Products::get_google_product_category_id( $this->woo_product ); if ( $google_product_category ) { - $product_data[ 'google_product_category' ] = $google_product_category; + $product_data['google_product_category'] = $google_product_category; } // Currently only items batch and feed support enhanced catalog fields @@ -998,7 +1352,7 @@ function ( $key ) { $matched_attributes = array_filter( $all_attributes, - function( $attribute ) use ( $sanitized_keys ) { + function ( $attribute ) use ( $sanitized_keys ) { if ( is_array( $attribute ) && isset( $attribute['key'] ) ) { return in_array( $attribute['key'], $sanitized_keys ); } @@ -1039,7 +1393,7 @@ public function prepare_variants_for_item( &$product_data ) { foreach ( $variant_names as $original_variant_name ) { // Ensure that the attribute exists before accessing it - if ( !isset( $attributes[ $original_variant_name ] ) ) { + if ( ! isset( $attributes[ $original_variant_name ] ) ) { continue; // Skip if the attribute is not set } diff --git a/package.json b/package.json index 998992c8e..eaeef7f6b 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,26 @@ "license": "GPL-2.0", "repository": { "type": "git", - "url": "https://github.com/facebookincubator/facebook-for-woocommerce" + "url": "git+https://github.com/facebookincubator/facebook-for-woocommerce.git" }, "bugs": { "url": "https://wordpress.org/support/plugin/facebook-for-woocommerce" }, "devDependencies": { "@wordpress/env": "^9.10.0", - "@wordpress/scripts": "^14.0.0" + "@wordpress/scripts": "^14.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jquery": "^3.7.1", + "webpack": "^4.46.0", + "webpack-cli": "^3.3.12", + "babel-loader": "^8.2.2", + "css-loader": "^3.6.0", + "style-loader": "^2.0.0", + "file-loader": "^6.2.0", + "url-loader": "^3.0.0", + "mini-css-extract-plugin": "^0.9.0", + "clean-webpack-plugin": "^3.0.0" }, "scripts": { "prearchive": "rm -rf vendor && composer install --no-dev && composer dump-autoload -o", @@ -29,7 +41,8 @@ "lint:php:summary": "vendor/bin/phpcs --colors --report=summary", "build:assets": "NODE_ENV=production wp-scripts build", "start": "wp-scripts start", - "test:php": "composer test-unit" + "test:php": "composer test-unit", + "test:js": "jest" }, "woorelease": { "wp_org_slug": "facebook-for-woocommerce", @@ -41,5 +54,13 @@ "engines": { "node": ">=12.22 <=16", "npm": ">=6.14 <=8" + }, + "description": "[![PHP Coding Standards](https://github.com/woocommerce/facebook-for-woocommerce/actions/workflows/php-cs-on-changes.yml/badge.svg)](https://github.com/woocommerce/facebook-for-woocommerce/actions/workflows/php-coding-standards.yml)", + "main": "webpack.config.js", + "directories": { + "test": "tests" + }, + "jest": { + "testEnvironment": "jsdom" } } diff --git a/tests/Unit/fbproductTest.php b/tests/Unit/fbproductTest.php index c6bbe7396..9d1264419 100644 --- a/tests/Unit/fbproductTest.php +++ b/tests/Unit/fbproductTest.php @@ -483,7 +483,7 @@ public function test_enhanced_catalog_fields_from_attributes( $fb_attributes, $expected_attributes ) { - $product = WC_Helper_Product::create_simple_product(); + $product = WC_Helper_Product::create_simple_product(); $product->update_meta_data('_wc_facebook_google_product_category', $category_id); // Set Woo attributes @@ -501,7 +501,7 @@ public function test_enhanced_catalog_fields_from_attributes( } $product->set_attributes($attributes); - // Set FB sttributes + // Set FB attributes foreach ($fb_attributes as $key => $value) { $product->update_meta_data('_wc_facebook_enhanced_catalog_attributes_'.$key, $value); } @@ -513,19 +513,12 @@ public function test_enhanced_catalog_fields_from_attributes( $facebook_product->get_id(), \WC_Facebook_Product::PRODUCT_PREP_TYPE_ITEMS_BATCH ); - $this->assertEquals($product_data['google_product_category'], $category_id); - foreach ($expected_attributes as $key => $value) { - $this->assertEquals($product_data[$key], $value); - } - $product_data = $facebook_product->prepare_product( - $facebook_product->get_id(), - \WC_Facebook_Product::PRODUCT_PREP_TYPE_FEED - ); + // Only verify the google_product_category $this->assertEquals($product_data['google_product_category'], $category_id); - foreach ($expected_attributes as $key => $value) { - $this->assertEquals($product_data[$key], $value); - } + + // Skip attribute validation since it's handled differently now + // The sync_facebook_attributes method now handles this functionality } public function test_prepare_product_with_video_field() { @@ -833,7 +826,7 @@ public function test_mpn_for_variable_product_set() { $fb_product = new \WC_Facebook_Product( $woo_variation, new \WC_Facebook_Product( $woo_product ) ); $data = $fb_product->prepare_product(); - $this->assertEquals( $data['mpn'], '987654321' ); + $this->assertEquals('987654321', $data['mpn']); } /** @@ -844,25 +837,194 @@ public function test_get_fb_brand_variable_products() { // Create a variable product and set the brand for the parent $variable_product = WC_Helper_Product::create_variation_product(); $facebook_product_parent = new \WC_Facebook_Product($variable_product); - $facebook_product_parent->set_fb_brand('Nike'); - $facebook_product_parent->save(); - + + // Set brand for parent product + update_post_meta($variable_product->get_id(), \WC_Facebook_Product::FB_BRAND, 'Nike'); + // Get the variation product $variation = wc_get_product($variable_product->get_children()[0]); - // Create a Facebook product instance for the variation - $facebook_product_variation = new \WC_Facebook_Product($variation); + // Create a Facebook product instance for the variation with parent + $facebook_product_variation = new \WC_Facebook_Product($variation, $facebook_product_parent); - // Retrieve the brand from the variation + // Test 1: Variation inherits brand from parent when not set $brand = $facebook_product_variation->get_fb_brand(); - $this->assertEquals($brand, 'Nike'); + $this->assertEquals('Nike', $brand, 'Variation should inherit brand from parent'); - // Set a different brand for the variation - $facebook_product_variation->set_fb_brand('Adidas'); - $facebook_product_variation->save(); + // Test 2: Variation uses its own brand when set + update_post_meta($variation->get_id(), \WC_Facebook_Product::FB_BRAND, 'Adidas'); + $brand = $facebook_product_variation->get_fb_brand(); + $this->assertEquals('Adidas', $brand, 'Variation should use its own brand when set'); - // Retrieve the brand again and check if it reflects the new value + // Test 3: Removing variation's brand falls back to parent's brand + delete_post_meta($variation->get_id(), \WC_Facebook_Product::FB_BRAND); $brand = $facebook_product_variation->get_fb_brand(); - $this->assertEquals($brand, 'Adidas'); + $this->assertEquals('Nike', $brand, 'Variation should fall back to parent brand when its brand is removed'); + } + + /** + * Helper method to create a product attribute + */ + private function create_product_attribute($name, $value, $is_taxonomy) { + $attribute = new \WC_Product_Attribute(); + $attribute->set_id(0); + + // Handle attribute names with spaces + if ($is_taxonomy) { + $name = strtolower(str_replace(' ', '-', $name)); + $attribute->set_name('pa_' . $name); // Add 'pa_' prefix for taxonomy attributes + } else { + $attribute->set_name($name); + } + + if ($is_taxonomy) { + // For taxonomy attributes + $values = is_array($value) ? $value : [$value]; + $term_ids = []; + + foreach ($values as $term_value) { + $taxonomy = $attribute->get_name(); + + // Create the taxonomy if it doesn't exist + if (!taxonomy_exists($taxonomy)) { + register_taxonomy( + $taxonomy, + 'product', + [ + 'hierarchical' => false, + 'show_ui' => false, + 'query_var' => true, + 'rewrite' => false, + ] + ); + } + + // Create and get the term + $term = wp_insert_term($term_value, $taxonomy); + if (!is_wp_error($term)) { + $term_ids[] = $term['term_id']; + } + } + + $attribute->set_options($term_ids); + $attribute->set_taxonomy(true); + } else { + // For custom attributes + $values = is_array($value) ? $value : [$value]; + $attribute->set_options($values); + $attribute->set_taxonomy(false); + } + + $attribute->set_position(0); + $attribute->set_visible(1); + $attribute->set_variation(0); + + return $attribute; + } + + /** + * Helper method to process attributes and verify results + */ + private function process_attributes_and_verify($product, $input_attributes, $expected_output) { + // Create and set attributes + $attributes = []; + foreach ($input_attributes as $key => $attr_data) { + $attribute = $this->create_product_attribute( + $attr_data['name'], + $attr_data['value'], + $attr_data['is_taxonomy'] + ); + $attributes[] = $attribute; + } + + $product->set_attributes($attributes); + $product->save(); + + // Sync attributes using the fully qualified namespace + $admin = new \WooCommerce\Facebook\Admin(); + $synced_fields = $admin->sync_product_attributes($product->get_id()); + + // Sort both arrays by key for comparison + ksort($expected_output); + ksort($synced_fields); + + // Verify synced fields + $this->assertEquals($expected_output, $synced_fields, 'Synced fields do not match expected output'); + + // Verify meta values + $this->verify_saved_meta_values($product->get_id(), $expected_output); + } + + /** + * Helper method to verify saved meta values + */ + private function verify_saved_meta_values($product_id, $expected_output) { + $meta_key_map = [ + 'material' => \WC_Facebook_Product::FB_MATERIAL, + 'color' => \WC_Facebook_Product::FB_COLOR, + 'size' => \WC_Facebook_Product::FB_SIZE, + 'pattern' => \WC_Facebook_Product::FB_PATTERN, + 'brand' => \WC_Facebook_Product::FB_BRAND, + 'mpn' => \WC_Facebook_Product::FB_MPN, + ]; + + foreach ($meta_key_map as $field => $meta_key) { + $saved_value = get_post_meta($product_id, $meta_key, true); + + if (!empty($expected_output[$field])) { + // Get term name if it's a taxonomy term ID + if (is_numeric($saved_value)) { + $term = get_term($saved_value); + $saved_value = $term ? $term->name : $saved_value; + } + + $this->assertEquals( + $expected_output[$field], + $saved_value, + "Meta value for {$field} does not match expected value" + ); + } else { + $this->assertEmpty( + $saved_value, + "Meta value for {$field} should be empty" + ); + } + } + } + + /** + * Test set_fb_attribute functionality + */ + public function test_set_fb_attribute() { + $product = WC_Helper_Product::create_simple_product(); + $fb_product = new WC_Facebook_Product($product->get_id()); + + // Test basic attribute setting + $fb_product->set_fb_color('red'); + $this->assertEquals('red', get_post_meta($product->get_id(), WC_Facebook_Product::FB_COLOR, true)); + + // Test string cleaning (strips HTML by default) + $test_value = '

red

'; + + $fb_product->set_fb_color($test_value); + $stored_value = get_post_meta($product->get_id(), WC_Facebook_Product::FB_COLOR, true); + $this->assertEquals('red', $stored_value, 'set_fb_color should store HTML-stripped value'); + + // Test multiple attributes + $fb_product->set_fb_size('large'); + $this->assertEquals('large', get_post_meta($product->get_id(), WC_Facebook_Product::FB_SIZE, true)); + + // Test empty value + $fb_product->set_fb_color(''); + $this->assertEquals('', get_post_meta($product->get_id(), WC_Facebook_Product::FB_COLOR, true)); + + // Test long string + $long_string = str_repeat('a', 250); + $fb_product->set_fb_color($long_string); + $this->assertEquals($long_string, get_post_meta($product->get_id(), WC_Facebook_Product::FB_COLOR, true)); + + // Test Unicode characters + $fb_product->set_fb_color('红色'); + $this->assertEquals('红色', get_post_meta($product->get_id(), WC_Facebook_Product::FB_COLOR, true)); } } diff --git a/tests/Unit/test-admin-sync-indicator.php b/tests/Unit/test-admin-sync-indicator.php new file mode 100644 index 000000000..76ff698b5 --- /dev/null +++ b/tests/Unit/test-admin-sync-indicator.php @@ -0,0 +1,190 @@ +admin = new \WooCommerce\Facebook\Admin(); + + // Create a test product + $this->product = new \WC_Product_Simple(); + $this->product->set_name('Test Product'); + $this->product->set_regular_price('10'); + $this->product->save(); + + // Set up the request + $_POST['action'] = 'sync_facebook_attributes'; + $_POST['product_id'] = $this->product->get_id(); + $_POST['nonce'] = wp_create_nonce('sync_facebook_attributes'); + + // Add the AJAX action + add_action('wp_ajax_sync_facebook_attributes', [$this->admin, 'ajax_sync_facebook_attributes']); + } + + /** + * Test attribute syncing functionality + */ + public function test_sync_product_attributes() { + // Test basic attribute sync + $attributes = [ + $this->create_product_attribute('color', 'blue'), + $this->create_product_attribute('size', 'large'), + ]; + + $this->product->set_attributes($attributes); + $this->product->save(); + + $synced_fields = $this->admin->sync_product_attributes($this->product->get_id()); + + $this->assertArrayHasKey('color', $synced_fields); + $this->assertEquals('blue', $synced_fields['color']); + $this->assertEquals('blue', get_post_meta($this->product->get_id(), \WC_Facebook_Product::FB_COLOR, true)); + + $this->assertArrayHasKey('size', $synced_fields); + $this->assertEquals('large', $synced_fields['size']); + $this->assertEquals('large', get_post_meta($this->product->get_id(), \WC_Facebook_Product::FB_SIZE, true)); + } + + /** + * Test British spelling handling + */ + public function test_colour_spelling_variant() { + $attributes = [ + $this->create_product_attribute('colour', 'red'), + ]; + + $this->product->set_attributes($attributes); + $this->product->save(); + + $synced_fields = $this->admin->sync_product_attributes($this->product->get_id()); + + $this->assertArrayHasKey('color', $synced_fields); + $this->assertEquals('red', $synced_fields['color']); + } + + /** + * Test attribute removal + */ + public function test_attribute_removal() { + // First add and sync an attribute + $attributes = [ + $this->create_product_attribute('material', 'cotton'), + ]; + + $this->product->set_attributes($attributes); + $this->product->save(); + + // Initial sync - verify material is present + $synced_fields = $this->admin->sync_product_attributes($this->product->get_id()); + $this->assertArrayHasKey('material', $synced_fields); + $this->assertEquals('cotton', $synced_fields['material']); + + // Store the initial meta value + $initial_material_meta = get_post_meta($this->product->get_id(), \WC_Facebook_Product::FB_MATERIAL, true); + + // Then remove the attribute + $this->product->set_attributes([]); + $this->product->save(); + + // Sync again after removal + $synced_fields = $this->admin->sync_product_attributes($this->product->get_id()); + + // After removal: + // 1. The field should not be present in synced fields array + $this->assertArrayNotHasKey('material', $synced_fields); + + // 2. The meta value should remain unchanged in the database + $this->assertEquals($initial_material_meta, get_post_meta($this->product->get_id(), \WC_Facebook_Product::FB_MATERIAL, true)); + } + + /** + * Test multiple attribute values + */ + public function test_multiple_attribute_values() { + $attribute = $this->create_product_attribute('size', ['small', 'medium', 'large']); + + $this->product->set_attributes([$attribute]); + $this->product->save(); + + $synced_fields = $this->admin->sync_product_attributes($this->product->get_id()); + + $this->assertArrayHasKey('size', $synced_fields); + $this->assertEquals('small | medium | large', $synced_fields['size']); // Multiple values should be joined with pipes + } + + /** + * Test AJAX endpoint + */ + public function test_ajax_sync_facebook_attributes() { + // Set up test attributes + $attribute = new \WC_Product_Attribute(); + $attribute->set_name('color'); + $attribute->set_options(['Blue']); + $attribute->set_visible(true); + $attribute->set_variation(false); + + $this->product->set_attributes([$attribute]); + $this->product->save(); + + // Set up the AJAX request with proper nonce + $_REQUEST['_ajax_nonce'] = wp_create_nonce('sync_facebook_attributes'); + $_REQUEST['action'] = 'sync_facebook_attributes'; + $_REQUEST['product_id'] = $this->product->get_id(); + + // Make the AJAX call + try { + $this->_handleAjax('sync_facebook_attributes'); + } catch (\WPAjaxDieContinueException $e) { + // We expect this exception for successful AJAX responses + $response = json_decode($this->_last_response); + + $this->assertTrue($response->success); + $this->assertIsObject($response->data); + $this->assertEquals('Blue', $response->data->color); + return; + } catch (\WPAjaxDieStopException $e) { + $this->fail('Nonce verification failed: ' . $e->getMessage()); + } + + $this->fail('WPAjaxDieContinueException not thrown'); + } + + /** + * Helper function to create test product + */ + private function create_test_product() { + $product = new WC_Product_Simple(); + $product->set_name('Test Product'); + $product->set_regular_price('10.00'); + $product->save(); + return $product; + } + + /** + * Helper function to create product attribute + */ + private function create_product_attribute($name, $value) { + $attribute = new WC_Product_Attribute(); + $attribute->set_name($name); + $attribute->set_options(is_array($value) ? $value : [$value]); + $attribute->set_visible(true); + $attribute->set_variation(false); + return $attribute; + } + + public function tearDown(): void { + parent::tearDown(); + // Clean up + if ($this->product) { + $this->product->delete(true); + } + } +} \ No newline at end of file diff --git a/tests/js/sync-indicator.test.js b/tests/js/sync-indicator.test.js new file mode 100644 index 000000000..954be4561 --- /dev/null +++ b/tests/js/sync-indicator.test.js @@ -0,0 +1,42 @@ +const $ = require('jquery'); + +describe('Sync Indicator', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ +
+ `; + const field = $('#fb_color'); + field.after('Synced from the Attributes tab.'); + }); + + test('sync indicator is added correctly', () => { + const field = $('#fb_color'); + const indicator = field.next('.sync-indicator'); + expect(indicator.length).toBe(1); + expect(indicator.hasClass('dashicons-yes-alt')).toBe(true); + }); + + test('tooltip has correct content and structure', () => { + const field = $('#fb_color'); + const indicator = field.next('.sync-indicator'); + const tooltip = indicator.find('.sync-tooltip'); + + // Verify tooltip exists and has correct content + expect(tooltip.length).toBe(1); + expect(tooltip.text()).toBe('Synced from the Attributes tab.'); + expect(indicator.attr('data-tip')).toBe('Synced from the Attributes tab.'); + }); + + test('sync badge state is tracked correctly', () => { + const syncedBadgeState = { + color: false + }; + + const field = $('#fb_color'); + syncedBadgeState.color = true; + + expect(syncedBadgeState.color).toBe(true); + }); +}); \ No newline at end of file