diff --git a/assets/css/admin/facebook-for-woocommerce-connection.css b/assets/css/admin/facebook-for-woocommerce-connection.css index 03a1016b8..77013e9be 100644 --- a/assets/css/admin/facebook-for-woocommerce-connection.css +++ b/assets/css/admin/facebook-for-woocommerce-connection.css @@ -82,6 +82,14 @@ min-height: calc(100vh - 200px); } +#facebook-commerce-iframe-enhanced { + width: 100%; + max-width: 1100px; + min-height: calc(100vh - 200px); + background: transparent; + border: none; +} + .woocommerce-embed-page #wpbody-content { padding-bottom: 0; -} \ No newline at end of file +} diff --git a/class-wc-facebookcommerce.php b/class-wc-facebookcommerce.php index bf457a206..b6952b172 100644 --- a/class-wc-facebookcommerce.php +++ b/class-wc-facebookcommerce.php @@ -60,6 +60,9 @@ class WC_Facebookcommerce extends WooCommerce\Facebook\Framework\Plugin { /** @var WooCommerce\Facebook\Admin\Settings */ private $admin_settings; + /** @var WooCommerce\Facebook\Admin\Enhanced_Settings */ + private $admin_enhanced_settings; + /** @var WooCommerce\Facebook\AJAX Ajax handler instance */ private $ajax; @@ -234,7 +237,11 @@ public function init() { // load admin handlers, before admin_init if ( is_admin() ) { - $this->admin_settings = new WooCommerce\Facebook\Admin\Settings( $this->connection_handler->is_connected() ); + if ($this->get_integration()->use_enhanced_onboarding()) { + $this->admin_enhanced_settings = new WooCommerce\Facebook\Admin\Enhanced_Settings( $this->connection_handler->is_connected() ); + } else { + $this->admin_settings = new WooCommerce\Facebook\Admin\Settings( $this->connection_handler->is_connected() ); + } } } } diff --git a/includes/Admin/Abstract_Settings_Screen.php b/includes/Admin/Abstract_Settings_Screen.php index 3b9765d6f..3ecca239f 100644 --- a/includes/Admin/Abstract_Settings_Screen.php +++ b/includes/Admin/Abstract_Settings_Screen.php @@ -129,9 +129,11 @@ protected function is_current_screen_page() { return false; } // assume we are on the Connection tab by default because the link under Marketing doesn't include the tab query arg - $connection_handler = facebook_for_woocommerce()->get_connection_handler(); - $default_tab = $connection_handler->is_connected() ? 'advertise' : 'connection'; - $tab = Helper::get_requested_value( 'tab', $default_tab ); + $connection_handler = facebook_for_woocommerce()->get_connection_handler(); + $use_enhanced_onboarding = facebook_for_woocommerce()->get_integration()->use_enhanced_onboarding(); + $default_tab = $use_enhanced_onboarding ? 'shops' : ( $connection_handler->is_connected() ? 'advertise' : 'connection' ); + $tab = Helper::get_requested_value( 'tab', $default_tab ); + return ! empty( $tab ) && $tab === $this->get_id(); } diff --git a/includes/Admin/Enhanced_Settings.php b/includes/Admin/Enhanced_Settings.php new file mode 100644 index 000000000..0f0b19c44 --- /dev/null +++ b/includes/Admin/Enhanced_Settings.php @@ -0,0 +1,406 @@ +screens = $this->build_menu_item_array( $is_connected ); + + add_action( 'admin_menu', array( $this, 'add_menu_item' ) ); + add_action( 'wp_loaded', array( $this, 'save' ) ); + + // TODO: Remove these hookds once catalog changes are complete + add_filter( 'parent_file', array( $this, 'set_parent_and_submenu_file' ) ); + add_action( 'all_admin_notices', array( $this, 'add_tabs_to_product_sets_taxonomy' ) ); + } + + /** + * Arranges the tabs. + * + * @since 3.5.0 + * + * @param bool $is_connected is Facebook connected + * @return array + */ + private function build_menu_item_array( bool $is_connected ): array { + if ( $is_connected ) { + // TODO: Add Utility messaging tab + // TODO: Remove Product sync and Product sets tab once catalog changes are complete + return array( + Settings_Screens\Shops::ID => new Settings_Screens\Shops(), + Settings_Screens\Advertise::ID => new Settings_Screens\Advertise(), + Settings_Screens\Product_Sync::ID => new Settings_Screens\Product_Sync(), + Settings_Screens\Product_Sets::ID => new Settings_Screens\Product_Sets(), + ); + } else { + // TODO: Add Utility messaging tab + return [ Settings_Screens\Shops::ID => new Settings_Screens\Shops() ]; + } + } + + /** + * Adds the Facebook menu item. + * + * @since 3.5.0 + */ + public function add_menu_item() { + $root_menu_item = $this->root_menu_item(); + + add_submenu_page( + $root_menu_item, + __( 'Facebook for WooCommerce', 'facebook-for-woocommerce' ), + __( 'Facebook', 'facebook-for-woocommerce' ), + 'manage_woocommerce', + self::PAGE_ID, + [ $this, 'render' ], + 5 + ); + + $this->connect_to_enhanced_admin( $this->is_marketing_enabled() ? 'marketing_page_wc-facebook' : 'woocommerce_page_wc-facebook' ); + } + + /** + * Gets the root menu item. + * + * @since 3.5.0 + * + * @return string + */ + public function root_menu_item() { + if ( $this->is_marketing_enabled() ) { + return 'woocommerce-marketing'; + } + + return 'woocommerce'; + } + + /** + * Checks if marketing feature is enabled. + * + * @since 3.5.0 + * + * @return bool + */ + public function is_marketing_enabled() { + if ( class_exists( WooAdminFeatures::class ) ) { + return WooAdminFeatures::is_enabled( 'marketing' ); + } + + return is_callable( '\Automattic\WooCommerce\Admin\Loader::is_feature_enabled' ) + && \Automattic\WooCommerce\Admin\Loader::is_feature_enabled( 'marketing' ); + } + + /** + * Enables enhanced admin support for the main Facebook settings page. + * + * @since 3.5.0 + * + * @param string $screen_id + */ + private function connect_to_enhanced_admin( $screen_id ) { + if ( is_callable( 'wc_admin_connect_page' ) ) { + $crumbs = array( + __( 'Facebook for WooCommerce', 'facebook-for-woocommerce' ), + ); + //phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['tab'] ) ) { + //phpcs:ignore WordPress.Security.NonceVerification.Recommended + switch ( $_GET['tab'] ) { + case Shops::ID: + $crumbs[] = __( 'Shops', 'facebook-for-woocommerce' ); + break; + case Settings_Screens\Product_Sync::ID: + $crumbs[] = __( 'Product sync', 'facebook-for-woocommerce' ); + break; + case Settings_Screens\Advertise::ID: + $crumbs[] = __( 'Advertise', 'facebook-for-woocommerce' ); + break; + } + } + wc_admin_connect_page( + array( + 'id' => self::PAGE_ID, + 'screen_id' => $screen_id, + 'path' => add_query_arg( 'page', self::PAGE_ID, 'admin.php' ), + 'title' => $crumbs, + ) + ); + } + } + + /** + * Renders the settings page. + * + * @since 3.5.0 + */ + public function render() { + $current_tab = $this->get_current_tab(); + $screen = $this->get_screen( $current_tab ); + + ?> +
+ render_tabs( $current_tab ); ?> + get_message_handler()->show_messages(); ?> + +

get_title() ); ?>

+

get_description() ); ?>

+ render(); ?> + +
+ get_tabs(); + + ?> + + get_tabs(); + $current_tab = Helper::get_requested_value( 'tab' ); + + if ( ! $current_tab ) { + $current_tab = current( array_keys( $tabs ) ); + } + + return $current_tab; + } + + /** + * Saves the settings page. + * + * @since 3.5.0 + */ + public function save() { + if ( ! is_admin() || Helper::get_requested_value( 'page' ) !== self::PAGE_ID ) { + return; + } + + $screen = $this->get_screen( Helper::get_posted_value( 'screen_id' ) ); + if ( ! $screen ) { + return; + } + + if ( ! Helper::get_posted_value( 'save_' . $screen->get_id() . '_settings' ) ) { + return; + } + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'You do not have permission to save these settings.', 'facebook-for-woocommerce' ) ); + } + + check_admin_referer( 'wc_facebook_admin_save_' . $screen->get_id() . '_settings' ); + try { + $screen->save(); + facebook_for_woocommerce()->get_message_handler()->add_message( __( 'Your settings have been saved.', 'facebook-for-woocommerce' ) ); + } catch ( PluginException $exception ) { + facebook_for_woocommerce()->get_message_handler()->add_error( + sprintf( + /* translators: Placeholders: %s - user-friendly error message */ + __( 'Your settings could not be saved. %s', 'facebook-for-woocommerce' ), + $exception->getMessage() + ) + ); + } + } + + /** + * Gets a settings screen object based on ID. + * + * @since 3.5.0 + * + * @param string $screen_id + * @return Abstract_Settings_Screen | null + */ + public function get_screen( $screen_id ) { + $screens = $this->get_screens(); + + return ! empty( $screens[ $screen_id ] ) && $screens[ $screen_id ] instanceof Abstract_Settings_Screen ? $screens[ $screen_id ] : null; + } + + /** + * Gets the available screens. + * + * @since 3.5.0 + * + * @return Abstract_Settings_Screen[] + */ + public function get_screens() { + /** + * Filters the admin settings screens. + * + * @since 3.5.0 + * + * @param array $screens + */ + $screens = (array) apply_filters( 'wc_facebook_admin_settings_screens', $this->screens, $this ); + + $screens = array_filter( + $screens, + function ( $value ) { + return $value instanceof Abstract_Settings_Screen; + } + ); + + return $screens; + } + + /** + * Gets the tabs. + * + * @since 3.5.0 + * + * @return array + */ + public function get_tabs() { + $tabs = []; + + foreach ( $this->get_screens() as $screen_id => $screen ) { + $tabs[ $screen_id ] = $screen->get_label(); + } + + /** + * Filters the admin settings tabs. + * + * @since 3.5.0 + * + * @param array $tabs + */ + return (array) apply_filters( 'wc_facebook_admin_settings_tabs', $tabs, $this ); + } + + /** + * TODO: Remove this function once catalog changes are complete + * + * Set the parent and submenu file while accessing Facebook Product Sets in the marketing menu. + * + * @since 3.5.0 + * + * @param string $parent_file + * @return string + */ + public function set_parent_and_submenu_file( $parent_file ) { + global $pagenow, $submenu_file; + + $root_menu_item = $this->root_menu_item(); + + if ( 'edit-tags.php' === $pagenow || 'term.php' === $pagenow ) { + if ( isset( $_GET['taxonomy'] ) && 'fb_product_set' === $_GET['taxonomy'] ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + $parent_file = $root_menu_item; + $submenu_file = self::PAGE_ID; //phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + } + + return $parent_file; + } + + /** + * TODO: Remove this function once catalog changes are complete + * + * Add the Facebook for WooCommerce tabs to the Facebook Product Set taxonomy page. + * Renders the tabs (hidden by default) at the stop of the page, + * then moves them to the correct DOM location with JavaScript and displays them. + * + * @since 3.3.0 + */ + public function add_tabs_to_product_sets_taxonomy() { + + // Only load this on the edit-tags.php page + $screen = get_current_screen(); + $is_taxonomy_list_page = 'edit-tags' === $screen->base; + $is_taxonomy_term_page = 'term' === $screen->base; + $is_taxonomy_page = $is_taxonomy_list_page || $is_taxonomy_term_page; + $is_product_set_taxonomy = 'fb_product_set' === $screen->taxonomy && $is_taxonomy_page; + + if ( $is_product_set_taxonomy ) { + $this->render_tabs( Settings_Screens\Product_Sets::ID ); + ?> + + is_current_screen_page() ) { + wp_enqueue_script( 'wp-api' ); + } + } + + /** + * Initializes this settings page's properties. + * + * @since 3.5.0 + */ + public function initHook(): void { + $this->id = self::ID; + $this->label = __( 'Shops', 'facebook-for-woocommerce' ); + $this->title = __( 'Shops', 'facebook-for-woocommerce' ); + } + + /** + * Adds admin notices. + * + * @since 3.5.0 + * + * @internal + */ + public function add_notices() { + if ( get_transient( 'wc_facebook_connection_failed' ) ) { + $message = sprintf( + /* translators: Placeholders: %1$s - tag, %2$s - tag, %3$s - tag, %4$s - tag, %5$s - tag, %6$s - tag */ + __( '%1$sHeads up!%2$s It looks like there was a problem with reconnecting your site to Facebook. Please %3$sclick here%4$s to try again, or %5$sget in touch with our support team%6$s for assistance.', 'facebook-for-woocommerce' ), + '', + '', + '', + '', + '', + '' + ); + + facebook_for_woocommerce()->get_admin_notice_handler()->add_admin_notice( + $message, + 'wc_facebook_connection_failed', + array( + 'notice_class' => 'error', + ) + ); + + delete_transient( 'wc_facebook_connection_failed' ); + } + } + + + /** + * Enqueues the assets. + * + * @since 3.5.0 + * + * @internal + */ + public function enqueue_assets() { + if ( ! $this->is_current_screen_page() ) { + return; + } + + wp_enqueue_style( 'wc-facebook-admin-connection-settings', facebook_for_woocommerce()->get_plugin_url() . '/assets/css/admin/facebook-for-woocommerce-connection.css', array(), \WC_Facebookcommerce::VERSION ); + } + + + /** + * Renders the screen. + * + * @since 3.5.0 + */ + public function render() { + $this->render_facebook_iframe(); + } + + /** + * Renders the appropriate Facebook iframe based on connection status. + * + * @since 3.5.0 + */ + private function render_facebook_iframe() { + $connection = facebook_for_woocommerce()->get_connection_handler(); + $is_connected = $connection->is_connected(); + $merchant_access_token = get_option( 'wc_facebook_merchant_access_token', '' ); + + if ( ! empty( $merchant_access_token ) && $is_connected ) { + $iframe_url = \WooCommerce\Facebook\Handlers\MetaExtension::generate_iframe_management_url( + $connection->get_external_business_id() + ); + } else { + $iframe_url = \WooCommerce\Facebook\Handlers\MetaExtension::generate_iframe_splash_url( + $is_connected, + $connection->get_plugin(), + $connection->get_external_business_id() + ); + } + + if ( empty( $iframe_url ) ) { + return; + } + + ?> +
+ +
+ is_current_screen_page() ) { + return; + } + + wp_add_inline_script( 'plugin-api-client', $this->generate_inline_enhanced_onboarding_script(), 'after' ); + } + + /** + * Generates the inline script for the enhanced onboarding flow. + * + * @since 3.5.0 + * + * @return string + */ + public function generate_inline_enhanced_onboarding_script() { + // Generate a fresh nonce for this request + $nonce = wp_json_encode( wp_create_nonce( 'wp_rest' ) ); + + // Create the inline script with HEREDOC syntax for better JS readability + return << f.feature_type === 'fb_shop')?.connected_assets?.commerce_merchant_settings_id || '', + ad_account_id: message.installed_features.find(f => f.feature_type === 'ads')?.connected_assets?.ad_account_id || '', + commerce_partner_integration_id: message.commerce_partner_integration_id || '', + profiles: message.profiles, + installed_features: message.installed_features + }; + + fbAPI.updateSettings(requestBody) + .then(function(response) { + if (response.success) { + window.location.reload(); + } else { + console.error('Error updating Facebook settings:', response); + } + }) + .catch(function(error) { + console.error('Error during settings update:', error); + }); + } + + if (messageEvent === 'CommerceExtension::RESIZE') { + const iframe = document.getElementById('facebook-commerce-iframe-enhanced'); + if (iframe && message.height) { + iframe.height = message.height; + } + } + + if (messageEvent === 'CommerceExtension::UNINSTALL') { + fbAPI.uninstallSettings() + .then(function(response) { + if (response.success) { + window.location.reload(); + } + }) + .catch(function(error) { + console.error('Error during uninstall:', error); + window.location.reload(); + }); + } + }); + JAVASCRIPT; + } + + + /** + * Gets the screen settings. + * + * @since 3.5.0 + * + * @return array + */ + public function get_settings() { + + return array( + + array( + 'title' => __( 'Debug', 'facebook-for-woocommerce' ), + 'type' => 'title', + ), + + array( + 'id' => \WC_Facebookcommerce_Integration::SETTING_ENABLE_DEBUG_MODE, + 'title' => __( 'Enable debug mode', 'facebook-for-woocommerce' ), + 'type' => 'checkbox', + 'desc' => __( 'Log plugin events for debugging.', 'facebook-for-woocommerce' ), + /* translators: %s URL to the documentation page. */ + 'desc_tip' => sprintf( __( 'Only enable this if you are experiencing problems with the plugin. Learn more.', 'facebook-for-woocommerce' ), 'https://woocommerce.com/document/facebook-for-woocommerce/#debug-tools' ), + 'default' => 'no', + ), + + array( + 'id' => \WC_Facebookcommerce_Integration::SETTING_ENABLE_NEW_STYLE_FEED_GENERATOR, + 'title' => __( 'Experimental! Enable new style feed generation', 'facebook-for-woocommerce' ), + 'type' => 'checkbox', + 'desc' => __( 'Use new, memory improved, feed generation process.', 'facebook-for-woocommerce' ), + /* translators: %s URL to the documentation page. */ + 'desc_tip' => sprintf( __( 'This is an experimental feature in testing phase. Only enable this if you are experiencing problems with feed generation. Learn more.', 'facebook-for-woocommerce' ), 'https://woocommerce.com/document/facebook-for-woocommerce/#feed-generation' ), + 'default' => 'no', + ), + array( 'type' => 'sectionend' ), + ); + } +} diff --git a/tests/Unit/Admin/Settings/ShopsTest.php b/tests/Unit/Admin/Settings/ShopsTest.php new file mode 100644 index 000000000..8b8be5f18 --- /dev/null +++ b/tests/Unit/Admin/Settings/ShopsTest.php @@ -0,0 +1,41 @@ +shops = new Shops(); + } + + /** + * Test that enqueue_assets enqueues the expected styles when on the page + */ + public function testEnqueueAssetsWhenNotOnPage(): void { + // Mock is_current_screen_page to return false + $shops = $this->getMockBuilder(Shops::class) + ->onlyMethods(['is_current_screen_page']) + ->getMock(); + + $shops->method('is_current_screen_page') + ->willReturn(false); + + // No styles should be enqueued + $shops->enqueue_assets(); + + $this->assertFalse(wp_style_is('wc-facebook-admin-connection-settings')); + } +} diff --git a/tests/Unit/Admin/Settings_Screens/ShopsTest.php b/tests/Unit/Admin/Settings_Screens/ShopsTest.php new file mode 100644 index 000000000..5b92a080e --- /dev/null +++ b/tests/Unit/Admin/Settings_Screens/ShopsTest.php @@ -0,0 +1,129 @@ +shops = new Shops(); + } + + /** + * Helper method to invoke private/protected methods + * + * @param object $object Object instance + * @param string $methodName Method name to call + * @param array $parameters Parameters to pass into method + * + * @return mixed Method return value + */ + private function invoke_method($object, $methodName, array $parameters = []) { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + + return $method->invokeArgs($object, $parameters); + } + + /** + * Test that render method calls render_facebook_iframe when enhanced onboarding is enabled + */ + public function test_render_facebook_box_iframe() { + // Create a mock of the Shops class + $shops = $this->getMockBuilder(Shops::class) + ->getMock(); + + // Start output buffering to capture the render output + ob_start(); + $shops->render(); + $output = ob_get_clean(); + + // Since we can't directly test the private render_facebook_iframe method, + // we'll verify that the render method doesn't output the legacy Facebook box + // when enhanced onboarding is enabled + $this->assertStringNotContainsString('wc-facebook-shops-box', $output); + } + + /** + * Test that render_message_handler outputs the expected JavaScript + */ + public function test_render_message_handler() { + // Create a mock of the Shops class + $shops_mock = $this->getMockBuilder(Shops::class) + ->onlyMethods(['is_current_screen_page']) + ->getMock(); + + // Configure the mock to return true for is_current_screen_page + $shops_mock->method('is_current_screen_page') + ->willReturn(true); + + // Call the method + $output = $shops_mock->generate_inline_enhanced_onboarding_script(); + + // Assert JavaScript event listeners and handlers + $this->assertStringContainsString('window.addEventListener(\'message\'', $output); + $this->assertStringContainsString('CommerceExtension::INSTALL', $output); + $this->assertStringContainsString('CommerceExtension::RESIZE', $output); + $this->assertStringContainsString('CommerceExtension::UNINSTALL', $output); + + // Assert fetch request setup - check for wpApiSettings.root instead of hardcoded path + $this->assertStringContainsString('GeneratePluginAPIClient', $output); + $this->assertStringContainsString('fbAPI.updateSettings', $output); + } + + /** + * Test that render_message_handler doesn't output when not on current screen + */ + public function test_render_message_handler_not_current_screen() { + // Create a mock of the Shops class + $shops_mock = $this->getMockBuilder(Shops::class) + ->onlyMethods(['is_current_screen_page']) + ->getMock(); + + $shops_mock->method('is_current_screen_page') + ->willReturn(false); + + // Start output buffering to capture the render output + ob_start(); + $shops_mock->render_message_handler(); + $output = ob_get_clean(); + + // Assert that no output is generated + $this->assertEmpty($output); + } + + /** + * Test that the management URL is used when merchant token exists + */ + public function test_renders_management_url_based_on_merchant_token() { + // Create a mock of the Shops class + $shops = $this->getMockBuilder(Shops::class) + ->getMock(); + + // Set up the merchant token + update_option('wc_facebook_merchant_access_token', 'test_token'); + + // Start output buffering to capture the render output + ob_start(); + $this->invoke_method($shops, 'render_facebook_iframe'); + $output = ob_get_clean(); + + // Check that the iframe is rendered + $this->assertStringContainsString('assertStringContainsString('id="facebook-commerce-iframe-enhanced"', $output); + } +}