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
+ );
} );
} );