diff --git a/packages/js/data/changelog/add-variable-product-type-spotlight b/packages/js/data/changelog/add-variable-product-type-spotlight new file mode 100644 index 00000000000..0000a4d9d0a --- /dev/null +++ b/packages/js/data/changelog/add-variable-product-type-spotlight @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add variable_product_tour_shown to UserPreferences type. diff --git a/packages/js/data/src/user/types.ts b/packages/js/data/src/user/types.ts index 122da493e98..0cd89db8d1d 100644 --- a/packages/js/data/src/user/types.ts +++ b/packages/js/data/src/user/types.ts @@ -25,6 +25,7 @@ export type UserPreferences = { [ key: string ]: number; }; taxes_report_columns?: string; + variable_product_tour_shown?: string; variations_report_columns?: string; }; diff --git a/plugins/woocommerce-admin/client/guided-tours/variable-product-tour/index.tsx b/plugins/woocommerce-admin/client/guided-tours/variable-product-tour/index.tsx new file mode 100644 index 00000000000..6ed69be7aff --- /dev/null +++ b/plugins/woocommerce-admin/client/guided-tours/variable-product-tour/index.tsx @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { TourKit, TourKitTypes } from '@woocommerce/components'; +import { useUserPreferences } from '@woocommerce/data'; +import { recordEvent } from '@woocommerce/tracks'; + +function getStepName( + steps: TourKitTypes.WooStep[], + currentStepIndex: number +) { + return steps[ currentStepIndex ]?.meta?.name; +} + +export const VariableProductTour = () => { + const [ isTourOpen, setIsTourOpen ] = useState( false ); + + const { updateUserPreferences, variable_product_tour_shown: hasShownTour } = + useUserPreferences(); + + const config: TourKitTypes.WooConfig = { + steps: [ + { + referenceElements: { + desktop: '.attribute_tab', + }, + focusElement: { + desktop: '.attribute_tab', + }, + meta: { + name: 'attributes', + heading: __( 'Start by adding attributes', 'woocommerce' ), + descriptions: { + desktop: __( + 'Add attributes like size and color for customers to choose from on the product page. We will use them to generate product variations.', + 'woocommerce' + ), + }, + primaryButton: { + text: __( 'Got it', 'woocommerce' ), + }, + }, + }, + ], + options: { + // WooTourKit does not handle merging of default options properly, + // so we need to duplicate the effects options here. + effects: { + spotlight: { + interactivity: { + enabled: true, + rootElementSelector: '#wpwrap', + }, + }, + arrowIndicator: true, + liveResize: { + mutation: true, + resize: true, + rootElementSelector: '#wpwrap', + }, + }, + }, + closeHandler: ( steps, currentStepIndex ) => { + updateUserPreferences( { variable_product_tour_shown: 'yes' } ); + setIsTourOpen( false ); + + if ( currentStepIndex === steps.length - 1 ) { + recordEvent( 'variable_product_tour_completed', { + step: getStepName( + steps as TourKitTypes.WooStep[], + currentStepIndex + ), + } ); + } else { + recordEvent( 'variable_product_tour_dismissed', { + step: getStepName( + steps as TourKitTypes.WooStep[], + currentStepIndex + ), + } ); + } + }, + }; + + // show the tour when the product type is changed to variable + useEffect( () => { + const productTypeSelect = document.querySelector( + '#product-type' + ) as HTMLSelectElement; + + if ( hasShownTour === 'yes' || ! productTypeSelect ) { + return; + } + + function handleProductTypeChange() { + if ( productTypeSelect.value === 'variable' ) { + setIsTourOpen( true ); + recordEvent( 'variable_product_tour_started', { + step: getStepName( config.steps, 0 ), + } ); + } + } + + productTypeSelect.addEventListener( 'change', handleProductTypeChange ); + + return () => { + productTypeSelect.removeEventListener( + 'change', + handleProductTypeChange + ); + }; + } ); + + if ( ! isTourOpen ) { + return null; + } + + return ; +}; diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/variable-product-tour/index.tsx b/plugins/woocommerce-admin/client/wp-admin-scripts/variable-product-tour/index.tsx new file mode 100644 index 00000000000..da8894416eb --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/variable-product-tour/index.tsx @@ -0,0 +1,13 @@ +/** + * External dependencies + */ +import { render } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { VariableProductTour } from '../../guided-tours/variable-product-tour'; + +const root = document.createElement( 'div' ); +root.setAttribute( 'id', 'variable-product-tour-root' ); +render( , document.body.appendChild( root ) ); diff --git a/plugins/woocommerce-admin/webpack.config.js b/plugins/woocommerce-admin/webpack.config.js index ab3ea444347..3be081d2085 100644 --- a/plugins/woocommerce-admin/webpack.config.js +++ b/plugins/woocommerce-admin/webpack.config.js @@ -65,6 +65,7 @@ const wpAdminScripts = [ 'settings-tracking', 'order-tracking', 'product-import-tracking', + 'variable-product-tour', ]; const getEntryPoints = () => { const entryPoints = { diff --git a/plugins/woocommerce/changelog/add-variable-product-type-spotlight b/plugins/woocommerce/changelog/add-variable-product-type-spotlight new file mode 100644 index 00000000000..39318829748 --- /dev/null +++ b/plugins/woocommerce/changelog/add-variable-product-type-spotlight @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Show tour when product type is changed to variable. diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-pointers.php b/plugins/woocommerce/includes/admin/class-wc-admin-pointers.php index d719d1f83d7..6721135b17a 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-pointers.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-pointers.php @@ -38,6 +38,7 @@ class WC_Admin_Pointers { switch ( $screen->id ) { case 'product': $this->create_product_tutorial(); + $this->create_variable_product_tutorial(); break; case 'woocommerce_page_wc-addons': $this->create_wc_addons_tutorial(); @@ -64,6 +65,17 @@ class WC_Admin_Pointers { WCAdminAssets::register_script( 'wp-admin-scripts', 'product-tour', true ); } + /** + * Pointers for creating a variable product. + */ + public function create_variable_product_tutorial() { + if ( ! current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + WCAdminAssets::register_script( 'wp-admin-scripts', 'variable-product-tour', true ); + } + /** * Pointers for accessing In-App Marketplace. */ diff --git a/plugins/woocommerce/src/Internal/Admin/WCAdminUser.php b/plugins/woocommerce/src/Internal/Admin/WCAdminUser.php index aafb85f8c44..748f4b96547 100644 --- a/plugins/woocommerce/src/Internal/Admin/WCAdminUser.php +++ b/plugins/woocommerce/src/Internal/Admin/WCAdminUser.php @@ -103,7 +103,13 @@ class WCAdminUser { * @return array Fields to expose over the WP user endpoint. */ public function get_user_data_fields() { - return apply_filters( 'woocommerce_admin_get_user_data_fields', array() ); + /** + * Filter user data fields exposed over the WordPress user endpoint. + * + * @since 4.0.0 + * @param array $fields Array of fields to expose over the WP user endpoint. + */ + return apply_filters( 'woocommerce_admin_get_user_data_fields', array( 'variable_product_tour_shown' ) ); } /** diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-variable-product.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-variable-product.spec.js index 1eebd2fec28..4214063412f 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-variable-product.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-variable-product.spec.js @@ -40,6 +40,32 @@ test.describe( 'Add New Variable Product Page', () => { await api.post( 'products/batch', { delete: ids } ); } ); + test( 'shows the variable product tour', async ( { page } ) => { + await page.goto( 'wp-admin/post-new.php?post_type=product' ); + await page.selectOption( '#product-type', 'variable', { force: true } ); + + // because of the way that the tour is dynamically positioned, + // Playwright can't automatically scroll the button into view, + // so we will manually scroll the attributes tab into view, + // which will cause the tour to be scrolled into view as well + await page + .locator( '.attribute_tab' ) + .getByRole( 'link', { name: 'Attributes' } ) + .scrollIntoViewIfNeeded(); + + // dismiss the variable product tour + await page + .getByRole( 'button', { name: 'Got it' } ) + .click( { force: true } ); + + // wait for the tour's dismissal to be saved + await page.waitForResponse( + ( response ) => + response.url().includes( '/users/' ) && + response.status() === 200 + ); + } ); + test( 'can create product, attributes and variations, edit variations and delete variations', async ( { page, } ) => { @@ -54,10 +80,18 @@ test.describe( 'Add New Variable Product Page', () => { if ( i > 0 ) { await page.click( 'button.add_attribute' ); } - await page.waitForSelector( `input[name="attribute_names[${ i }]"]` ); + await page.waitForSelector( + `input[name="attribute_names[${ i }]"]` + ); - await page.locator( `input[name="attribute_names[${ i }]"]` ).first().type( `attr #${ i + 1 }` ); - await page.locator( `textarea[name="attribute_values[${ i }]"]` ).first().type( 'val1 | val2' ); + await page + .locator( `input[name="attribute_names[${ i }]"]` ) + .first() + .type( `attr #${ i + 1 }` ); + await page + .locator( `textarea[name="attribute_values[${ i }]"]` ) + .first() + .type( 'val1 | val2' ); } await page.click( 'text=Save attributes' ); // wait for the attributes to be saved @@ -167,10 +201,11 @@ test.describe( 'Add New Variable Product Page', () => { } ); const variationsCount = await page.$$( '.woocommerce_variation' ); await expect( variationsCount ).toHaveLength( 0 ); - } ); - test( 'can manually add a variation, manage stock levels, set variation defaults and remove a variation', async ( { page } ) => { + test( 'can manually add a variation, manage stock levels, set variation defaults and remove a variation', async ( { + page, + } ) => { await page.goto( 'wp-admin/post-new.php?post_type=product' ); await page.fill( '#title', manualVariableProduct ); await page.selectOption( '#product-type', 'variable', { force: true } ); @@ -180,10 +215,18 @@ test.describe( 'Add New Variable Product Page', () => { if ( i > 0 ) { await page.click( 'button.add_attribute' ); } - await page.waitForSelector( `input[name="attribute_names[${ i }]"]` ); + await page.waitForSelector( + `input[name="attribute_names[${ i }]"]` + ); - await page.locator( `input[name="attribute_names[${ i }]"]` ).first().type( `attr #${ i + 1 }` ); - await page.locator( `textarea[name="attribute_values[${ i }]"]` ).first().type( 'val1 | val2' ); + await page + .locator( `input[name="attribute_names[${ i }]"]` ) + .first() + .type( `attr #${ i + 1 }` ); + await page + .locator( `textarea[name="attribute_values[${ i }]"]` ) + .first() + .type( 'val1 | val2' ); } await page.click( 'text=Save attributes' ); // wait for the attributes to be saved @@ -289,6 +332,8 @@ test.describe( 'Add New Variable Product Page', () => { page.on( 'dialog', ( dialog ) => dialog.accept() ); await page.hover( '.woocommerce_variation' ); await page.click( '.remove_variation.delete' ); - await expect( page.locator( '.woocommerce_variation' ) ).toHaveCount( 0 ); + await expect( page.locator( '.woocommerce_variation' ) ).toHaveCount( + 0 + ); } ); } );