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/changelog.txt b/changelog.txt index a1d82530c..6a1dafa7c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,12 @@ *** Facebook for WooCommerce Changelog *** += 3.4.5 - 2025-04-01 = +* Tweak - Add new product field external_update_time to measure product update latency by @mshymon in #2973 +* Fix - for 'PHP Warning: Undefined variable $fb_product_parent' by @mshymon in #2976 +* Fix - Updated logic to choose/create the feed for product sync by @mshymon in #2989 +* Add - Facebook Product Data Tab Enhancement by @devbodaghe in #2938 +* Fix - PHP Warning for empty attributes by @vinkmeta in #3001 + = 3.4.4 - 2025-03-26 = * Add - Create tests for ProductFeedUploads create endpoint by @ajello-meta in #2902 * Add - Create tests for ProductFeedUploads read endpoint by @ajello-meta in #2903 diff --git a/facebook-commerce.php b/facebook-commerce.php index ba6f83855..7b5290bec 100644 --- a/facebook-commerce.php +++ b/facebook-commerce.php @@ -132,9 +132,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'; @@ -715,7 +715,7 @@ public function load_assets() { }, feed: { totalVisibleProducts: 'get_product_count() ); ?>', - hasClientSideFeedUpload: 'get_feed_id() ); ?>', + hasClientSideFeedUpload: 'get_feed_id() ); ?>', enabled: true, format: 'csv' }, @@ -805,20 +805,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 ) { @@ -843,16 +841,58 @@ public function on_product_save( int $wp_id ) { } /** - * 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 ) { - $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 ] ) ) ); + * 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( $product ) { + $woo_product = new WC_Facebook_Product( $product->get_id() ); + $this->save_facebook_product_attributes( $woo_product ); } /** @@ -889,14 +929,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 ); } /** @@ -921,7 +954,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; } @@ -1070,8 +1103,7 @@ public function delete_draft_product( $post ) { return; } - $this->on_product_delete ( $post->ID ); - + $this->on_product_delete( $post->ID ); } @@ -1629,7 +1661,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 ) { @@ -2223,7 +2255,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(); @@ -2647,24 +2679,35 @@ public function is_product_sync_enabled() { } /** - * Return true if (legacy) feed generation is enabled. - * - * Feed generation for product sync is enabled by default, and generally recommended. - * Large stores, or stores running on shared hosting (low resources) may have issues - * with feed generation. This option allows those stores to disable generation to - * work around the issue. - * - * Note - this is temporary. In a future release, an improved feed system will be - * implemented, which should work well for all stores. This option will not disable - * the new improved implementation. - * - * @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' ); - } + * Return true if (legacy) feed generation is enabled. + * + * Feed generation for product sync is enabled by default, and generally recommended. + * Large stores, or stores running on shared hosting (low resources) may have issues + * with feed generation. This option allows those stores to disable generation to + * work around the issue. + * + * Note - this is temporary. In a future release, an improved feed system will be + * implemented, which should work well for all stores. This option will not disable + * the new improved implementation. + * + * @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' ); + } + + /** + * Determines whether meta diagnosis is enabled. + * + * @return bool + * @since 3.4.4 + * + */ + public function is_meta_diagnosis_enabled() { + return (bool) ( 'yes' === get_option( self::SETTING_ENABLE_META_DIAGNOSIS ) ); + } /** * Determines whether debug mode is enabled. @@ -2900,7 +2943,7 @@ 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; + return; } try { $set_visibility = $this->facebook_for_woocommerce->get_api()->update_product_item( $fb_product_item_id, [ 'visibility' => $visibility ] ); diff --git a/facebook-for-woocommerce.php b/facebook-for-woocommerce.php index a5b1d4742..cf04f12a3 100644 --- a/facebook-for-woocommerce.php +++ b/facebook-for-woocommerce.php @@ -10,7 +10,7 @@ * Description: Grow your business on Facebook! Use this official plugin to help sell more of your products using Facebook. After completing the setup, you'll be ready to create ads that promote your products and you can also create a shop section on your Page where customers can browse your products on Facebook. * Author: Facebook * Author URI: https://www.facebook.com/ - * Version: 3.4.4 + * Version: 3.4.5 * Requires at least: 5.6 * Requires PHP: 7.4 * Text Domain: facebook-for-woocommerce @@ -48,7 +48,7 @@ class WC_Facebook_Loader { /** * @var string the plugin version. This must be in the main plugin file to be automatically bumped by Woorelease. */ - const PLUGIN_VERSION = '3.4.4'; // WRCS: DEFINED_VERSION. + const PLUGIN_VERSION = '3.4.5'; // WRCS: DEFINED_VERSION. // Minimum PHP version required by this plugin. const MINIMUM_PHP_VERSION = '7.4.0'; diff --git a/includes/Admin.php b/includes/Admin.php index 896f21be3..1b8367d62 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -73,6 +73,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' ) ); @@ -103,9 +104,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' ) ); } /** @@ -181,7 +184,7 @@ public function enqueue_scripts() { 'i18n' => array( 'top_level_dropdown_placeholder' => __( 'Search main categories...', 'facebook-for-woocommerce' ), 'second_level_empty_dropdown_placeholder' => __( 'Choose a main category first', 'facebook-for-woocommerce' ), - 'general_dropdown_placeholder' => __( 'Choose a category', 'facebook-for-woocommerce' ), + 'general_dropdown_placeholder' => __( 'Choose a category', 'facebook-for-woocommerce' ), ), ) ); @@ -215,7 +218,7 @@ public function enqueue_scripts() { 'facebook_for_woocommerce_product_sets', array( - 'excluded_category_ids' => facebook_for_woocommerce()->get_integration()->get_excluded_product_category_ids(), + 'excluded_category_ids' => facebook_for_woocommerce()->get_integration()->get_excluded_product_category_ids(), 'excluded_category_warning_message' => __( 'You have selected one or more categories currently excluded from the Facebook sync. Products belonging to the excluded categories will not be added to your Facebook Product Set.', 'facebook-for-woocommerce' ), ) ); @@ -238,22 +241,22 @@ public function enqueue_scripts() { 'facebook-for-woocommerce-products-admin', 'facebook_for_woocommerce_products_admin', [ - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'enhanced_attribute_optional_selector' => Enhanced_Catalog_Attribute_Fields::FIELD_ENHANCED_CATALOG_ATTRIBUTE_PREFIX . Enhanced_Catalog_Attribute_Fields::OPTIONAL_SELECTOR_KEY, - 'enhanced_attribute_page_type_edit_category' => Enhanced_Catalog_Attribute_Fields::PAGE_TYPE_EDIT_CATEGORY, - 'enhanced_attribute_page_type_add_category' => Enhanced_Catalog_Attribute_Fields::PAGE_TYPE_ADD_CATEGORY, - 'enhanced_attribute_page_type_edit_product' => Enhanced_Catalog_Attribute_Fields::PAGE_TYPE_EDIT_PRODUCT, - 'is_product_published' => $this->is_current_product_published(), - 'is_sync_enabled_for_product' => $this->is_sync_enabled_for_current_product(), - 'set_product_visibility_nonce' => wp_create_nonce( 'set-products-visibility' ), - 'set_product_sync_prompt_nonce' => wp_create_nonce( 'set-product-sync-prompt' ), - 'set_product_sync_bulk_action_prompt_nonce' => wp_create_nonce( 'set-product-sync-bulk-action-prompt' ), - 'product_not_ready_modal_message' => $this->get_product_not_ready_modal_message(), - 'product_not_ready_modal_buttons' => $this->get_product_not_ready_modal_buttons(), + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'enhanced_attribute_optional_selector' => Enhanced_Catalog_Attribute_Fields::FIELD_ENHANCED_CATALOG_ATTRIBUTE_PREFIX . Enhanced_Catalog_Attribute_Fields::OPTIONAL_SELECTOR_KEY, + 'enhanced_attribute_page_type_edit_category' => Enhanced_Catalog_Attribute_Fields::PAGE_TYPE_EDIT_CATEGORY, + 'enhanced_attribute_page_type_add_category' => Enhanced_Catalog_Attribute_Fields::PAGE_TYPE_ADD_CATEGORY, + 'enhanced_attribute_page_type_edit_product' => Enhanced_Catalog_Attribute_Fields::PAGE_TYPE_EDIT_PRODUCT, + 'is_product_published' => $this->is_current_product_published(), + 'is_sync_enabled_for_product' => $this->is_sync_enabled_for_current_product(), + 'set_product_visibility_nonce' => wp_create_nonce( 'set-products-visibility' ), + 'set_product_sync_prompt_nonce' => wp_create_nonce( 'set-product-sync-prompt' ), + 'set_product_sync_bulk_action_prompt_nonce' => wp_create_nonce( 'set-product-sync-bulk-action-prompt' ), + 'product_not_ready_modal_message' => $this->get_product_not_ready_modal_message(), + 'product_not_ready_modal_buttons' => $this->get_product_not_ready_modal_buttons(), 'product_removed_from_sync_confirm_modal_message' => $this->get_product_removed_from_sync_confirm_modal_message(), 'product_removed_from_sync_confirm_modal_buttons' => $this->get_product_removed_from_sync_confirm_modal_buttons(), - 'product_removed_from_sync_field_id' => '#' . \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, - 'i18n' => [ + 'product_removed_from_sync_field_id' => '#' . \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, + 'i18n' => [ 'missing_google_product_category_message' => __( 'Please enter a Google product category and at least one sub-category to sell this product on Instagram.', 'facebook-for-woocommerce' ), ], ] @@ -265,7 +268,6 @@ public function enqueue_scripts() { wp_enqueue_script( 'wc-enhanced-select' ); } }//end if - } /** @@ -557,7 +559,7 @@ public function filter_products_by_sync_enabled( $query_vars ) { /** @var \WC_Product[] $found_products */ foreach ( $found_products as $product ) { if ( ! Products::is_sync_enabled_for_product( $product ) - || ! Products::is_product_visible( $product ) ) { + || ! Products::is_product_visible( $product ) ) { $exclude_products[] = $product->get_id(); } } @@ -587,7 +589,7 @@ public function filter_products_by_sync_enabled( $query_vars ) { /** @var \WC_Product[] $found_products */ foreach ( $found_products as $product ) { if ( ! Products::is_sync_enabled_for_product( $product ) - || Products::is_product_visible( $product ) ) { + || Products::is_product_visible( $product ) ) { $exclude_products[] = $product->get_id(); } } @@ -618,15 +620,15 @@ public function filter_products_by_sync_enabled( $query_vars ) { $variable_product = wc_get_product( $variation_post->post_parent ); // we need this check because we only want products with ALL variations hidden if ( $variable_product instanceof \WC_Product && Products::is_sync_enabled_for_product( $variable_product ) - && ! Products::is_product_visible( $variable_product ) ) { + && ! Products::is_product_visible( $variable_product ) ) { $include_products[] = $variable_product->get_id(); } } } else { // self::SYNC_MODE_SYNC_DISABLED // products to be included in the QUERY, not in the sync - $include_products = []; - $found_ids = []; + $include_products = []; + $found_ids = []; $integration = facebook_for_woocommerce()->get_integration(); $excluded_categories_ids = $integration ? $integration->get_excluded_product_category_ids() : []; $excluded_tags_ids = $integration ? $integration->get_excluded_product_tag_ids() : []; @@ -648,7 +650,7 @@ public function filter_products_by_sync_enabled( $query_vars ) { ), ) ); - $include_products = array_unique( array_merge( $include_products, $excluded_products ) ); + $include_products = array_unique( array_merge( $include_products, $excluded_products ) ); // since we record enabled status and visibility on child variations, // we need to include variable products with excluded children $excluded_variations = get_posts( @@ -903,15 +905,13 @@ public function handle_products_sync_bulk_actions( $redirect ) { if ( 'facebook_include' === $action ) { if ( $product->is_virtual() && ! Products::is_sync_enabled_for_product( $product ) ) { $enabling_sync_virtual_products[ $product->get_id() ] = $product; - } else { - if ( $product->is_type( 'variable' ) ) { + } elseif ( $product->is_type( 'variable' ) ) { // collect the virtual variations - foreach ( $product->get_children() as $variation_id ) { - $variation = wc_get_product( $variation_id ); - if ( $variation && $variation->is_virtual() && ! Products::is_sync_enabled_for_product( $variation ) ) { - $enabling_sync_virtual_products[ $product->get_id() ] = $product; - $enabling_sync_virtual_variations[ $variation->get_id() ] = $variation; - } + foreach ( $product->get_children() as $variation_id ) { + $variation = wc_get_product( $variation_id ); + if ( $variation && $variation->is_virtual() && ! Products::is_sync_enabled_for_product( $variation ) ) { + $enabling_sync_virtual_products[ $product->get_id() ] = $product; + $enabling_sync_virtual_variations[ $variation->get_id() ] = $variation; } } }//end if @@ -1122,7 +1122,7 @@ public function filter_virtual_products_affected_enabling_sync( $query_vars ) { public function add_handled_virtual_products_variations_notice() { if ( 'yes' === get_option( 'wc_facebook_background_handle_virtual_products_variations_complete', 'no' ) && - 'yes' !== get_option( 'wc_facebook_background_handle_virtual_products_variations_skipped', 'no' ) ) { + 'yes' !== get_option( 'wc_facebook_background_handle_virtual_products_variations_skipped', 'no' ) ) { facebook_for_woocommerce()->get_admin_notice_handler()->add_admin_notice( sprintf( @@ -1164,61 +1164,61 @@ public function add_product_settings_tab( $tabs ) { return $tabs; } - /** - * Outputs the form field for Facebook Product Videos with a description tip. - * - * @param array $video_urls Array of video URLs. - */ - private function render_facebook_product_video_field( $video_urls ) { - $attachment_ids = []; - - // Output the form field for Facebook Product Videos with a description tip - ?> -

- - - -

-
- -

- - - -

- -
- - \WC_Facebook_Product::FB_PRODUCT_VIDEO, - 'name' => \WC_Facebook_Product::FB_PRODUCT_VIDEO, - 'value' => esc_attr( implode( ',', $attachment_ids ) ), // Store attachment IDs - ] - ); - } + /** + * Outputs the form field for Facebook Product Videos with a description tip. + * + * @param array $video_urls Array of video URLs. + */ + private function render_facebook_product_video_field( $video_urls ) { + $attachment_ids = []; + + // Output the form field for Facebook Product Videos with a description tip + ?> +

+ + + +

+
+ +

+ + + +

+ +
+ + \WC_Facebook_Product::FB_PRODUCT_VIDEO, + 'name' => \WC_Facebook_Product::FB_PRODUCT_VIDEO, + 'value' => esc_attr( implode( ',', $attachment_ids ) ), // Store attachment IDs + ] + ); + } /** * Adds content to the new Facebook tab on the Product edit page. @@ -1230,19 +1230,25 @@ private function render_facebook_product_video_field( $video_urls ) { public function add_product_settings_tab_content() { global $post; - // all products have sync enabled unless explicitly disabled $sync_enabled = 'no' !== get_post_meta( $post->ID, Products::SYNC_ENABLED_META_KEY, true ); $is_visible = ( $visibility = get_post_meta( $post->ID, Products::VISIBILITY_META_KEY, true ) ) ? wc_string_to_bool( $visibility ) : true; - $product = wc_get_product( $post ); - - $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 ); + $product = wc_get_product( $post ); + + $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; @@ -1258,9 +1264,9 @@ public function add_product_settings_tab_content() { woocommerce_wp_select( array( - 'id' => 'wc_facebook_sync_mode', - 'label' => __( 'Facebook Sync', 'facebook-for-woocommerce' ), - 'options' => array( + 'id' => 'wc_facebook_sync_mode', + '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' ), @@ -1272,114 +1278,240 @@ public function add_product_settings_tab_content() { ); echo '
'; - echo ''; + echo ''; wp_editor( $rich_text_description, \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, array( - 'id' => 'wc_facebook_sync_mode', + 'id' => 'wc_facebook_sync_mode', 'textarea_name' => \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, 'textarea_rows' => 10, 'media_buttons' => true, - 'teeny' => true, - 'quicktags' => false, - 'tinymce' => array( + 'teeny' => true, + 'quicktags' => false, + 'tinymce' => array( 'toolbar1' => 'bold,italic,bullist,spellchecker,fullscreen', ), ) ); echo '
'; - woocommerce_wp_radio( + woocommerce_wp_radio( + array( + 'id' => 'fb_product_image_source', + 'label' => __( 'Facebook Product Image', 'facebook-for-woocommerce' ), + 'desc_tip' => true, + 'description' => __( 'Choose the product image that should be synced to the Facebook catalog and displayed for this product.', 'facebook-for-woocommerce' ), + 'options' => array( + Products::PRODUCT_IMAGE_SOURCE_PRODUCT => __( 'Use WooCommerce image', 'facebook-for-woocommerce' ), + Products::PRODUCT_IMAGE_SOURCE_CUSTOM => __( 'Use custom image', 'facebook-for-woocommerce' ), + ), + 'value' => $image_source ?: Products::PRODUCT_IMAGE_SOURCE_PRODUCT, + 'class' => 'short enable-if-sync-enabled js-fb-product-image-source', + 'wrapper_class' => 'fb-product-image-source-field', + ) + ); + + woocommerce_wp_text_input( + array( + 'id' => \WC_Facebook_Product::FB_PRODUCT_IMAGE, + 'label' => __( 'Custom Image URL', 'facebook-for-woocommerce' ), + 'value' => $image, + 'class' => sprintf( 'enable-if-sync-enabled product-image-source-field show-if-product-image-source-%s', Products::PRODUCT_IMAGE_SOURCE_CUSTOM ), + 'desc_tip' => true, + 'description' => __( 'Please enter an absolute URL (e.g. https://domain.com/image.jpg).', 'facebook-for-woocommerce' ), + ) + ); + + $this->render_facebook_product_video_field( $video_urls ); + + woocommerce_wp_text_input( + array( + 'id' => \WC_Facebook_Product::FB_PRODUCT_PRICE, + 'label' => sprintf( + /* translators: Placeholders %1$s - WC currency symbol */ + __( 'Facebook Price (%1$s)', 'facebook-for-woocommerce' ), + get_woocommerce_currency_symbol() + ), + 'desc_tip' => true, + 'description' => __( 'Custom price for product on Facebook. Please enter in monetary decimal (.) format without thousand separators and currency symbols. If blank, product price will be used.', 'facebook-for-woocommerce' ), + 'cols' => 40, + 'rows' => 60, + 'value' => $price, + 'class' => 'enable-if-sync-enabled', + ) + ); + + woocommerce_wp_hidden_input( array( - 'id' => 'fb_product_image_source', - 'label' => __( 'Facebook Product Image', 'facebook-for-woocommerce' ), - 'desc_tip' => true, - 'description' => __( 'Choose the product image that should be synced to the Facebook catalog and displayed for this product.', 'facebook-for-woocommerce' ), - 'options' => array( - Products::PRODUCT_IMAGE_SOURCE_PRODUCT => __( 'Use WooCommerce image', 'facebook-for-woocommerce' ), - Products::PRODUCT_IMAGE_SOURCE_CUSTOM => __( 'Use custom image', 'facebook-for-woocommerce' ), - ), - 'value' => $image_source ?: Products::PRODUCT_IMAGE_SOURCE_PRODUCT, - 'class' => 'short enable-if-sync-enabled js-fb-product-image-source', - 'wrapper_class' => 'fb-product-image-source-field', + 'id' => \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, + 'value' => '', ) ); + ?> + + +
+

+ + + + +

+
+ + + \WC_Facebook_Product::FB_PRODUCT_IMAGE, - 'label' => __( 'Custom Image URL', 'facebook-for-woocommerce' ), - 'value' => $image, - 'class' => sprintf( 'enable-if-sync-enabled product-image-source-field show-if-product-image-source-%s', Products::PRODUCT_IMAGE_SOURCE_CUSTOM ), - 'desc_tip' => true, - 'description' => __( 'Please enter an absolute URL (e.g. https://domain.com/image.jpg).', 'facebook-for-woocommerce' ), + '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, ) ); - $this->render_facebook_product_video_field( $video_urls ); - woocommerce_wp_text_input( array( - 'id' => \WC_Facebook_Product::FB_PRODUCT_PRICE, - 'label' => sprintf( - /* translators: Placeholders %1$s - WC currency symbol */ - __( 'Facebook Price (%1$s)', 'facebook-for-woocommerce' ), - get_woocommerce_currency_symbol() + '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_select( + array( + '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' => __( 'Custom price for product on Facebook. Please enter in monetary decimal (.) format without thousand separators and currency symbols. If blank, product price will be used.', 'facebook-for-woocommerce' ), + 'description' => __( 'This refers to the condition of your product. Supported values are new, refurbished and used.', 'facebook-for-woocommerce' ), + ) + ); + + 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' => $price, + 'value' => $fb_size, 'class' => 'enable-if-sync-enabled', ) ); woocommerce_wp_text_input( array( - 'id' => \WC_Facebook_Product::FB_BRAND, - 'label' => __( 'Brand', 'facebook-for-woocommerce' ), - 'value' => $fb_brand, - 'class' => 'enable-if-sync-enabled', + '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_MPN, - 'label' => __( 'Manufacturer Parts Number (MPN)', 'facebook-for-woocommerce' ), - 'value' => $fb_mpn, - 'class' => 'enable-if-sync-enabled', + '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_hidden_input( + woocommerce_wp_text_input( array( - 'id' => \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, - 'value' => '', + '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', ) ); - ?> - - -
- \WC_Facebook_Product::FB_VARIABLE_BRAND, - 'label' => __( 'Brand', 'facebook-for-woocommerce' ), - 'value' => $fb_brand, - 'class' => 'enable-if-sync-enabled', - ) - ); - ?> -
- -
+ ?> + +
@@ -1399,7 +1531,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 ) { @@ -1412,13 +1543,14 @@ 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 ); $is_visible = ( $visibility = $this->get_product_variation_meta( $variation, Products::VISIBILITY_META_KEY, $parent ) ) ? wc_string_to_bool( $visibility ) : true; - $description = $this->get_product_variation_meta( $variation, \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, $parent ); + $description = $this->get_product_variation_meta( $variation, \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, $parent ); $price = $this->get_product_variation_meta( $variation, \WC_Facebook_Product::FB_PRODUCT_PRICE, $parent ); $image_url = $this->get_product_variation_meta( $variation, \WC_Facebook_Product::FB_PRODUCT_IMAGE, $parent ); $image_source = $variation->get_meta( Products::PRODUCT_IMAGE_SOURCE_META_KEY ); - $fb_mpn = $this->get_product_variation_meta( $variation, \WC_Facebook_Product::FB_MPN, $parent ); + $fb_mpn = $this->get_product_variation_meta( $variation, \WC_Facebook_Product::FB_MPN, $parent ); if ( $sync_enabled ) { $sync_mode = $is_visible ? self::SYNC_MODE_SYNC_AND_SHOW : self::SYNC_MODE_SYNC_AND_HIDE; @@ -1426,97 +1558,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', - ) - ); + ?> +
+

+ +
+

+ +
+ + update_meta_data( \WC_Facebookcommerce_Integration::FB_PRODUCT_DESCRIPTION, $description ); $variation->update_meta_data( \WC_Facebookcommerce_Integration::FB_RICH_TEXT_DESCRIPTION, $description ); - $variation->update_meta_data( Products::PRODUCT_IMAGE_SOURCE_META_KEY, $image_source ); + $variation->update_meta_data( Products::PRODUCT_IMAGE_SOURCE_META_KEY, $image_source ); $variation->update_meta_data( \WC_Facebook_Product::FB_MPN, $fb_mpn ); $variation->update_meta_data( \WC_Facebook_Product::FB_PRODUCT_IMAGE, $image_url ); - $variation->update_meta_data( \WC_Facebook_Product::FB_PRODUCT_VIDEO, $video_urls ); + $variation->update_meta_data( \WC_Facebook_Product::FB_PRODUCT_VIDEO, $video_urls ); $variation->update_meta_data( \WC_Facebook_Product::FB_PRODUCT_PRICE, $price ); $variation->save_meta_data(); } else { @@ -1630,5 +1799,217 @@ public function render_modal_template() { + + 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 ); ?> - - array( 'simple', 'variation' ), 'include' => $items, @@ -99,7 +99,7 @@ protected function process_items( array $items, array $args ) { 'limit' => $this->get_batch_size(), ) ); - $feed_handler = new \WC_Facebook_Product_Feed(); + $feed_handler = new \WC_Facebook_Product_Feed(); $temp_feed_file = fopen( $feed_handler->get_temp_file_path(), 'a' ); $feed_handler->write_products_feed_to_temp_file( $products, $temp_feed_file ); if ( is_resource( $temp_feed_file ) ) { diff --git a/includes/Products.php b/includes/Products.php index c0d38ff9d..c578c913d 100644 --- a/includes/Products.php +++ b/includes/Products.php @@ -42,7 +42,7 @@ class Products { /** @var string product image source option to use the parent product image in Facebook */ const PRODUCT_IMAGE_SOURCE_CUSTOM = 'custom'; - + /** @var string the meta key used to store the Google product category ID for the product */ const GOOGLE_PRODUCT_CATEGORY_META_KEY = '_wc_facebook_google_product_category'; diff --git a/includes/Products/FBCategories.php b/includes/Products/FBCategories.php index 0d1655923..a36d1a760 100644 --- a/includes/Products/FBCategories.php +++ b/includes/Products/FBCategories.php @@ -21,8 +21,25 @@ */ class FBCategories { - /** @var array Keys to exclude from attribute processing */ - private $keys_to_exclude = [ 'brand' => 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/Products/Feed.php b/includes/Products/Feed.php index 75114dd79..187918797 100644 --- a/includes/Products/Feed.php +++ b/includes/Products/Feed.php @@ -218,26 +218,15 @@ public function send_request_to_upload_feed() { * @internal */ public function retrieve_or_create_integration_feed_id() { - // Step 1 - Get feed ID if it is already available in local cache - $feed_id = facebook_for_woocommerce()->get_integration()->get_feed_id(); - if ( $feed_id ) { - if ( self::validate_feed_exists( $feed_id ) ) { - WC_Facebookcommerce_Utils::log( 'Feed: feed_id = ' . $feed_id . ', from local cache was validated.' ); - return $feed_id; - } else { - WC_Facebookcommerce_Utils::log( 'Feed: feed_id = ' . $feed_id . ', from local cache was invalidated.' ); - } - } - - // Step 2 - Query feeds data from Meta and filter the right one - $feed_id = self::query_and_filter_integration_feed_id(); + // Attempt 1. Request feeds data from Meta and filter the right one + $feed_id = self::request_and_filter_integration_feed_id(); if ( $feed_id ) { facebook_for_woocommerce()->get_integration()->update_feed_id( $feed_id ); - WC_Facebookcommerce_Utils::log( 'Feed: feed_id = ' . $feed_id . ', queried and filtered from Meta API.' ); + WC_Facebookcommerce_Utils::log( 'Feed: feed_id = ' . $feed_id . ', queried and selected from Meta API.' ); return $feed_id; } - // Step 3 - Create a new feed + // Attempt 2. Create a new feed $feed_id = self::create_feed_id(); if ( $feed_id ) { facebook_for_woocommerce()->get_integration()->update_feed_id( $feed_id ); @@ -248,38 +237,6 @@ public function retrieve_or_create_integration_feed_id() { return ''; } - /** - * Validates that provided feed ID still exists on the Meta side - * - * @param string $feed_id the feed ID - * - * @return bool true if the feed ID is valid - * - * @internal - * @throws Exception|Error If there is an error getting feed nodes or if no catalog ID is available. - */ - private function validate_feed_exists( $feed_id ) { - try { - $catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); - if ( '' === $catalog_id ) { - throw new Error( 'No catalog ID' ); - } - $feed_nodes = facebook_for_woocommerce()->get_api()->read_feeds( $catalog_id )->data; - } catch ( Exception $e ) { - $message = sprintf( 'There was an error trying to get feed nodes for catalog: %s', $e->getMessage() ); - WC_Facebookcommerce_Utils::log( $message ); - return ''; - } - - foreach ( $feed_nodes as $feed ) { - if ( $feed['id'] == $feed_id ) { - return true; - } - } - - return false; - } - /** * Queries existing feeds for the integration catalog and filters * the plugin integration feed ID @@ -289,7 +246,7 @@ private function validate_feed_exists( $feed_id ) { * @internal * @throws Exception|Error If there is an error getting feed nodes, catalog, or if no catalog ID is available. */ - private function query_and_filter_integration_feed_id() { + private function request_and_filter_integration_feed_id() { try { $catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); if ( '' === $catalog_id ) { @@ -306,20 +263,12 @@ private function query_and_filter_integration_feed_id() { return ''; } - try { - $catalog = facebook_for_woocommerce()->get_api()->get_catalog( $catalog_id ); - } catch ( Exception $e ) { - $message = sprintf( 'There was an error trying to get a catalog: %s', $e->getMessage() ); - WC_Facebookcommerce_Utils::log( $message ); - } - /* We need to detect which feed is the one that was created for Facebook for WooCommerce plugin usage. We are detecting based on the name. - Option 1. Plugin can create this feed name currently. - - Option 2 and 3. FBE creates a catalog with feed name '{catalog name} - Feed' or '{catalog name} – Feed' (short vs long dash) - - Option 4. Plugin used to create a feed name 'Initial product sync from WooCommerce. DO NOT DELETE.' + - Option 2. Plugin used to create a feed name 'Initial product sync from WooCommerce. DO NOT DELETE.' */ foreach ( $feed_nodes as $feed ) { try { @@ -330,15 +279,8 @@ private function query_and_filter_integration_feed_id() { continue; } - $woo_feed_name_option_1 = self::FEED_NAME; - $woo_feed_name_option_2 = sprintf( '%s - Feed', $catalog['name'] ); - $woo_feed_name_option_3 = sprintf( '%s – Feed', $catalog['name'] ); - $woo_feed_name_option_4 = 'Initial product sync from WooCommerce. DO NOT DELETE.'; - - if ( $feed_metadata['name'] === $woo_feed_name_option_1 || - $feed_metadata['name'] === $woo_feed_name_option_2 || - $feed_metadata['name'] === $woo_feed_name_option_3 || - $feed_metadata['name'] === $woo_feed_name_option_4 ) { + if ( self::FEED_NAME === $feed_metadata['name'] || + 'Initial product sync from WooCommerce. DO NOT DELETE.' === $feed_metadata['name'] ) { return $feed['id']; } } diff --git a/includes/fbproduct.php b/includes/fbproduct.php index 12a3b41b5..5c05e6b06 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 @@ -896,6 +1250,10 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel $product_data['gtin'] = $gtin; } + if ( $date_modified = $this->woo_product->get_date_modified() ) { + $product_data[ 'external_update_time' ] = $date_modified->getTimestamp(); + } + // Only use checkout URLs if they exist. $checkout_url = $this->build_checkout_url( $product_url ); if ( $checkout_url ) { @@ -958,6 +1316,10 @@ private function apply_enhanced_catalog_fields_from_attributes( $product_data, $ $all_attributes = $category_handler->get_attributes_with_fallback_to_parent_category( $google_category_id ); + if ( empty( $all_attributes ) ) { + return $product_data; + } + foreach ( $all_attributes as $attribute ) { $value = Products::get_enhanced_catalog_attribute( $attribute['key'], $this->woo_product ); $convert_to_array = ( @@ -998,7 +1360,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 +1401,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/includes/fbproductfeed.php b/includes/fbproductfeed.php index 373ccf306..feb326dfe 100644 --- a/includes/fbproductfeed.php +++ b/includes/fbproductfeed.php @@ -322,6 +322,7 @@ public function write_products_feed_to_temp_file( $wp_ids, $temp_feed_file ) { foreach ( $wp_ids as $wp_id ) { $product = wc_get_product( $wp_id ); + $fb_product_parent = null; if ( $product instanceof WC_Product && $product->get_parent_id() ) { $parent_product = wc_get_product( $product->get_parent_id() ); if ( $parent_product instanceof WC_Product ) { @@ -383,7 +384,7 @@ public function get_product_feed_header_row() { return 'id,title,description,image_link,link,product_type,' . 'brand,price,availability,item_group_id,checkout_url,' . 'additional_image_link,sale_price_effective_date,sale_price,condition,' . - 'visibility,gender,color,size,pattern,google_product_category,default_product,variant,gtin,quantity_to_sell_on_facebook,rich_text_description' . PHP_EOL; + 'visibility,gender,color,size,pattern,google_product_category,default_product,variant,gtin,quantity_to_sell_on_facebook,rich_text_description,external_update_time' . PHP_EOL; } @@ -532,7 +533,8 @@ private function prepare_product_for_feed( $woo_product, &$attribute_variants ) static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'variant' )) . ',' . static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'gtin' )) . ',' . static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'quantity_to_sell_on_facebook' )) . ',' . - static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'rich_text_description' ) ) . PHP_EOL; + static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'rich_text_description' ) ) . ',' . + static::get_value_from_product_data( $product_data, 'external_update_time' ) . PHP_EOL; } private static function format_additional_image_url( $product_image_urls ) { diff --git a/package.json b/package.json index f1115ed4a..6c1ceb3f5 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,31 @@ { "name": "facebook-for-woocommerce", - "version": "3.4.4", + "version": "3.4.5", "author": "Facebook", "homepage": "https://woocommerce.com/products/facebook/", "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/readme.txt b/readme.txt index 90bcb5813..675b8596d 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: facebook, automattic, woothemes Tags: facebook, woocommerce, marketing, product catalog feed, pixel Requires at least: 5.6 Tested up to: 6.7 -Stable tag: 3.4.4 +Stable tag: 3.4.5 Requires PHP: 7.4 MySQL: 5.6 or greater License: GPLv2 or later @@ -40,18 +40,11 @@ When opening a bug on GitHub, please give us as many details as possible. == Changelog == -= 3.4.4 - 2025-03-26 = -* Add - Create tests for ProductFeedUploads create endpoint by @ajello-meta in #2902 -* Add - Create tests for ProductFeedUploads read endpoint by @ajello-meta in #2903 -* Tweak - Remove phpcs:ignoreFile annotation + Enable code coverage report generation with phpunit by @sol-loup in #2897 and #2901 -* Fix - Restores the original dynamic property behavior in the AsyncRequest class by @sol-loup in #2921 -* Tweak - Changing APP to PLUGIN on README.MD by @SayanPandey in #2916 -* Tweak - Update README.md - Added noification for ownership transfer by @SayanPandey in #2910 -* Tweak - Added is_multisite logging to the update_plugin_version_configuration request by @carterbuce in #2955 -* Tweak - Add woo_commerce_retailer_id to products API request by @crisojog in #2958 -* Tweak - Syncing plugin version info by @vinkmeta in #2960 -* Fix - sync products out of stock to meta despite visibility config by @francorisso in #2952 -* Fix - Update woo_commerce_retailer_id to existing field external_variant_id by @crisojog in #2963 -* Tweak - Update readme.txt by @vinkmeta in #2949 += 3.4.5 - 2025-04-01 = +* Tweak - Add new product field external_update_time to measure product update latency by @mshymon in #2973 +* Fix - for 'PHP Warning: Undefined variable $fb_product_parent' by @mshymon in #2976 +* Fix - Updated logic to choose/create the feed for product sync by @mshymon in #2989 +* Add - Facebook Product Data Tab Enhancement by @devbodaghe in #2938 +* Fix - PHP Warning for empty attributes by @vinkmeta in #3001 [See changelog for all versions](https://raw.githubusercontent.com/facebook/facebook-for-woocommerce/refs/heads/main/changelog.txt). diff --git a/tests/Unit/fbproductTest.php b/tests/Unit/fbproductTest.php index d0884b5fb..7be356234 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() { @@ -832,7 +825,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']); } /** @@ -843,25 +836,224 @@ 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)); + } + + /** + * Test external_update_time is populated + * @return void + */ + public function test_external_update_time_set() { + $woo_product = WC_Helper_Product::create_simple_product(); + + $timestamp = time(); + $woo_product->set_date_modified($timestamp); + + $fb_product = new \WC_Facebook_Product( $woo_product ); + $data = $fb_product->prepare_product(); + + $this->assertEquals( $data['external_update_time'], $timestamp); + } + + /** + * Test external_update_time is not populated + * @return void + */ + public function test_external_update_time_unset() { + $woo_product = WC_Helper_Product::create_simple_product(); + $woo_product->set_date_modified(null); + + $fb_product = new \WC_Facebook_Product( $woo_product ); + $data = $fb_product->prepare_product(); + + $this->assertEquals(isset($data['external_update_time']), false); } } 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