diff --git a/packages/js/internal-style-build/abstracts/_variables.scss b/packages/js/internal-style-build/abstracts/_variables.scss
index 73155c89baf..812b09a7464 100644
--- a/packages/js/internal-style-build/abstracts/_variables.scss
+++ b/packages/js/internal-style-build/abstracts/_variables.scss
@@ -45,6 +45,7 @@ $alert-green: $valid-green;
$adminbar-height: 32px;
$adminbar-height-mobile: 46px;
$admin-menu-width: 160px;
+$admin-menu-width-collapsed: 36px;
// wp-admin colors
$wp-admin-background: #f1f1f1;
diff --git a/plugins/woocommerce-admin/client/products/add-product-page.tsx b/plugins/woocommerce-admin/client/products/add-product-page.tsx
index 6b0ab353f11..7702b9e5ac8 100644
--- a/plugins/woocommerce-admin/client/products/add-product-page.tsx
+++ b/plugins/woocommerce-admin/client/products/add-product-page.tsx
@@ -8,6 +8,7 @@ import { useEffect } from '@wordpress/element';
* Internal dependencies
*/
import { ProductForm } from './product-form';
+import { ProductTourContainer } from './tour';
import './product-page.scss';
const AddProductPage: React.FC = () => {
@@ -18,6 +19,7 @@ const AddProductPage: React.FC = () => {
return (
);
};
diff --git a/plugins/woocommerce-admin/client/products/tour/index.tsx b/plugins/woocommerce-admin/client/products/tour/index.tsx
new file mode 100644
index 00000000000..4aaf84f546c
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/index.tsx
@@ -0,0 +1 @@
+export * from './product-tour-container';
diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx
new file mode 100644
index 00000000000..86bec8ccbc8
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx
@@ -0,0 +1,21 @@
+/**
+ * Internal dependencies
+ */
+import { ProductTour } from './product-tour';
+import { ProductTourModal } from './product-tour-modal';
+import { useProductTour } from './use-product-tour';
+
+export const ProductTourContainer: React.FC = () => {
+ const { dismissModal, endTour, isModalHidden, isTouring, startTour } =
+ useProductTour();
+
+ if ( isTouring ) {
+ return ;
+ }
+
+ if ( isModalHidden ) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss
new file mode 100644
index 00000000000..c515f50e2f4
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss
@@ -0,0 +1,76 @@
+.woocommerce-product-tour-modal {
+ max-width: 400px;
+ position: fixed;
+ left: $admin-menu-width + $gap-large;
+ bottom: $gap-large;
+ // This puts the modal on top of the RichTextEditor toolbars.
+ z-index: 31;
+
+ @include breakpoint( '<960px' ) {
+ left: $admin-menu-width-collapsed + $gap-large;
+ }
+
+ @include breakpoint( '<782px' ) {
+ display: none;
+ }
+
+ .components-modal__content {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ padding: 0;
+ }
+
+ .components-modal__header {
+ position: static;
+ padding-top: $gap;
+ padding-left: $gap;
+ padding-right: $gap;
+ height: auto;
+
+ .components-button {
+ position: absolute;
+ top: $gap;
+ right: $gap;
+ left: auto;
+ }
+
+ .components-modal__header-heading {
+ font-size: 16px;
+ line-height: 24px;
+ }
+ }
+
+ .woocommerce-product-tour-modal__header-img {
+ background: #c5d9ed;
+ order: -1;
+ padding: 28px 28px 0 28px;
+
+ img {
+ max-width: 286px;
+ display: block;
+ margin: 0 auto;
+ }
+ }
+
+ .woocommerce-product-tour-modal__content {
+ padding: $gap-smaller $gap $gap $gap;
+
+ > p:first-child {
+ margin-top: 0;
+ }
+ }
+
+ .woocommerce-product-tour-modal__actions {
+ text-align: right;
+ margin-top: 28px;
+
+ button {
+ margin-left: $gap-smaller;
+ }
+ }
+}
+
+.woocommerce-product-tour-modal__overlay {
+ position: static;
+}
diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx
new file mode 100644
index 00000000000..95a60b2997a
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, Modal } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import ProductTourImage from './product-tour.png';
+import './product-tour-modal.scss';
+
+type ProductTourModalProps = {
+ onClose: () => void;
+ onStart: () => void;
+};
+
+export const ProductTourModal: React.FC< ProductTourModalProps > = ( {
+ onClose,
+ onStart,
+} ) => {
+ return (
+ onClose() }
+ overlayClassName="woocommerce-product-tour-modal__overlay"
+ shouldCloseOnClickOutside={ false }
+ title={ __( 'Meet the product editing form', 'woocommerce' ) }
+ >
+
+
+
+
+
+ { __(
+ 'Let us show you how to navigate the form and create this product from start to finish in no time.',
+ 'woocommerce'
+ ) }
+
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.png b/plugins/woocommerce-admin/client/products/tour/product-tour.png
new file mode 100644
index 00000000000..858df4b9322
Binary files /dev/null and b/plugins/woocommerce-admin/client/products/tour/product-tour.png differ
diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx
new file mode 100644
index 00000000000..3d6d272acc6
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TourKit, TourKitTypes } from '@woocommerce/components';
+
+type ProductTourProps = {
+ onClose: () => void;
+};
+
+export const ProductTour: React.FC< ProductTourProps > = ( { onClose } ) => {
+ const tourConfig: TourKitTypes.WooConfig = {
+ placement: 'auto',
+ options: {
+ effects: {
+ spotlight: {
+ interactivity: {
+ enabled: false,
+ },
+ },
+ liveResize: {
+ mutation: true,
+ resize: true,
+ },
+ },
+ },
+ steps: [
+ {
+ referenceElements: {
+ desktop: `.woocommerce-product-form-tab__general .woocommerce-form-section__content`,
+ },
+ meta: {
+ name: 'story',
+ heading: __(
+ 'đź“Ł Tell a story about your product',
+ 'woocommerce'
+ ),
+ descriptions: {
+ desktop: __(
+ 'The product form will help you describe your product field by field—from basic details like name and description to attributes the customers can use to find it on your store.',
+ 'woocommerce'
+ ),
+ },
+ },
+ },
+ {
+ referenceElements: {
+ desktop: `#tab-panel-0-pricing`,
+ },
+ meta: {
+ name: 'tabs',
+ heading: __( '✍️ Set up pricing & more', 'woocommerce' ),
+ descriptions: {
+ desktop: __(
+ 'When done, use the tabs to switch between other details and settings. In the future, you’ll also find here extensions and plugins.',
+ 'woocommerce'
+ ),
+ },
+ },
+ },
+ {
+ referenceElements: {
+ desktop: `.woocommerce-product-form-actions`,
+ },
+ meta: {
+ name: 'actions',
+ heading: __( '🔍 Preview and publish', 'woocommerce' ),
+ descriptions: {
+ desktop: __(
+ 'With all the details in place, use the buttons at the top to easily preview and publish your product. Click the arrow button for more options.',
+ 'woocommerce'
+ ),
+ },
+ },
+ },
+ {
+ referenceElements: {
+ desktop: `.woocommerce-product-form-more-menu`,
+ },
+ meta: {
+ name: 'more',
+ heading: __( '⚙️ Looking for more?', 'woocommerce' ),
+ descriptions: {
+ desktop: __(
+ 'If the form doesn’t yet have all the feautures you need—it’s still in development—you can switch to the classic editor anytime.',
+ 'woocommerce'
+ ),
+ },
+ },
+ },
+ ],
+ closeHandler: onClose,
+ };
+
+ return ;
+};
diff --git a/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx
new file mode 100644
index 00000000000..17d236ae1fe
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx
@@ -0,0 +1,94 @@
+/**
+ * External dependencies
+ */
+import userEvent from '@testing-library/user-event';
+import { render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { ProductTourContainer } from '../';
+import { useProductTour } from '../use-product-tour';
+
+const dismissModal = jest.fn();
+const endTour = jest.fn();
+const startTour = jest.fn();
+
+const defaultValues = {
+ dismissModal,
+ endTour,
+ isModalHidden: false,
+ isTouring: false,
+ startTour,
+};
+
+jest.mock( '../use-product-tour', () => {
+ return {
+ useProductTour: jest.fn(),
+ };
+} );
+
+const mockedUseProductTour = useProductTour as jest.Mock;
+
+describe( 'ProductTourContainer', () => {
+ it( 'should render the modal initially if not already hidden', () => {
+ mockedUseProductTour.mockImplementation( () => defaultValues );
+ const { getByText } = render( );
+ expect(
+ getByText( 'Meet the product editing form' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should not render the modal when the tour has already started', () => {
+ mockedUseProductTour.mockImplementation( () => ( {
+ ...defaultValues,
+ isTouring: true,
+ } ) );
+ const { queryByText } = render( );
+ expect(
+ queryByText( 'Meet the product editing form' )
+ ).not.toBeInTheDocument();
+ expect(
+ queryByText( 'Tell a story about your product' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'should call startTour after clicking the button to begin the tour', () => {
+ mockedUseProductTour.mockImplementation( () => defaultValues );
+ const { getByText } = render( );
+ userEvent.click( getByText( 'Show me around (10s)' ) );
+ expect( startTour ).toBeCalled();
+ } );
+
+ it( 'should call dismissModal after closing the modal', () => {
+ mockedUseProductTour.mockImplementation( () => defaultValues );
+ const { getByText } = render( );
+ userEvent.click( getByText( "I'll explore on my own" ) );
+ expect( dismissModal ).toBeCalled();
+ } );
+
+ it( 'should call endTour after closing the tour', () => {
+ mockedUseProductTour.mockImplementation( () => ( {
+ ...defaultValues,
+ isTouring: true,
+ } ) );
+ const { getByLabelText } = render( );
+ userEvent.click( getByLabelText( 'Close Tour' ) );
+ expect( endTour ).toBeCalled();
+ } );
+
+ it( 'should not show tour or modal once tour is complete', () => {
+ mockedUseProductTour.mockImplementation( () => ( {
+ ...defaultValues,
+ isTouring: false,
+ isModalHidden: true,
+ } ) );
+ const { queryByText } = render( );
+ expect(
+ queryByText( 'Meet the product editing form' )
+ ).not.toBeInTheDocument();
+ expect(
+ queryByText( 'Tell a story about your product' )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx
new file mode 100644
index 00000000000..6fc011e3f01
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx
@@ -0,0 +1,101 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useDispatch, useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { PRODUCT_TOUR_MODAL_HIDDEN, useProductTour } from '../use-product-tour';
+
+jest.mock( '@wordpress/data', () => {
+ // Require the original module to not be mocked...
+ const originalModule = jest.requireActual( '@wordpress/data' );
+
+ return {
+ __esModule: true, // Use it when dealing with esModules
+ ...originalModule,
+ useDispatch: jest.fn().mockReturnValue( {} ),
+ useSelect: jest.fn().mockReturnValue( {} ),
+ };
+} );
+
+const mockedUseDispatch = useDispatch as jest.Mock;
+const mockedUseSelect = useSelect as jest.Mock;
+
+describe( 'useProductTour', () => {
+ it( 'should initially set the tour to hidden', () => {
+ const { result } = renderHook( () => useProductTour() );
+ expect( result.current.isTouring ).toBeFalsy();
+ } );
+
+ it( 'should update the tour state when starting the tour', () => {
+ const updateOptions = jest.fn();
+ mockedUseDispatch.mockImplementation( () => ( {
+ updateOptions,
+ } ) );
+ const { result } = renderHook( () => useProductTour() );
+ act( () => {
+ result.current.startTour();
+ } );
+ expect( result.current.isTouring ).toBeTruthy();
+ } );
+
+ it( 'should dismiss the modal when starting the tour', () => {
+ const updateOptions = jest.fn();
+ mockedUseDispatch.mockImplementation( () => ( {
+ updateOptions,
+ } ) );
+ const { result } = renderHook( () => useProductTour() );
+
+ act( () => {
+ result.current.startTour();
+ } );
+
+ expect( updateOptions ).toHaveBeenCalledWith( {
+ [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes',
+ } );
+ } );
+
+ it( 'should update the tour state when ending the tour', () => {
+ const { result } = renderHook( () => useProductTour() );
+ act( () => {
+ result.current.startTour();
+ result.current.endTour();
+ } );
+ expect( result.current.isTouring ).toBeFalsy();
+ } );
+
+ it( 'should return true when the modal is hidden', () => {
+ mockedUseSelect.mockImplementation( () => ( {
+ isModalHidden: true,
+ } ) );
+ const { result } = renderHook( () => useProductTour() );
+ expect( result.current.isModalHidden ).toBeTruthy();
+ } );
+
+ it( 'should return false when the modal is hidden', () => {
+ mockedUseSelect.mockImplementation( () => ( {
+ isModalHidden: false,
+ } ) );
+ const { result } = renderHook( () => useProductTour() );
+ expect( result.current.isModalHidden ).toBeFalsy();
+ } );
+
+ it( 'should dismiss the modal when manually called', () => {
+ const updateOptions = jest.fn();
+ mockedUseDispatch.mockImplementation( () => ( {
+ updateOptions,
+ } ) );
+ const { result } = renderHook( () => useProductTour() );
+
+ act( () => {
+ result.current.dismissModal();
+ } );
+
+ expect( updateOptions ).toHaveBeenCalledWith( {
+ [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts
new file mode 100644
index 00000000000..c8bd7dbec3b
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts
@@ -0,0 +1,50 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { OPTIONS_STORE_NAME } from '@woocommerce/data';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { useState } from '@wordpress/element';
+
+export const PRODUCT_TOUR_MODAL_HIDDEN =
+ 'woocommerce_product_tour_modal_hidden';
+
+export const useProductTour = () => {
+ const [ isTouring, setIsTouring ] = useState( false );
+ const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
+ const { isModalHidden } = useSelect( ( select ) => {
+ const { getOption, hasFinishedResolution } =
+ select( OPTIONS_STORE_NAME );
+
+ return {
+ isModalHidden:
+ getOption( PRODUCT_TOUR_MODAL_HIDDEN ) === 'yes' ||
+ ! hasFinishedResolution( 'getOption', [
+ PRODUCT_TOUR_MODAL_HIDDEN,
+ ] ),
+ };
+ } );
+
+ const dismissModal = () => {
+ updateOptions( {
+ [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes',
+ } );
+ };
+
+ const endTour = () => {
+ setIsTouring( false );
+ };
+
+ const startTour = () => {
+ dismissModal();
+ setIsTouring( true );
+ };
+
+ return {
+ dismissModal,
+ endTour,
+ isModalHidden,
+ isTouring,
+ startTour,
+ };
+};
diff --git a/plugins/woocommerce/changelog/add-36322 b/plugins/woocommerce/changelog/add-36322
new file mode 100644
index 00000000000..0f45f7d8cb3
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-36322
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add product tour to new product management experience