/** * External dependencies */ import { Sender, createMachine } from 'xstate'; import { useEffect, useMemo, useState } from '@wordpress/element'; import { useMachine, useSelector } from '@xstate/react'; import { getNewPath, getQuery, updateQueryString, } from '@woocommerce/navigation'; import { OPTIONS_STORE_NAME } from '@woocommerce/data'; import { dispatch } from '@wordpress/data'; import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ import { useFullScreen } from '~/utils'; import { Intro, events as introEvents, services as introServices, actions as introActions, } from './intro'; import { DesignWithAi, events as designWithAiEvents } from './design-with-ai'; import { AssemblerHub, events as assemblerHubEvents } from './assembler-hub'; import { Transitional, events as transitionalEvents, services as transitionalServices, } from './transitional'; import { findComponentMeta } from '~/utils/xstate/find-component'; import { CustomizeStoreComponentMeta, CustomizeStoreComponent, customizeStoreStateMachineContext, } from './types'; import { ThemeCard } from './intro/types'; import './style.scss'; export type customizeStoreStateMachineEvents = | introEvents | designWithAiEvents | assemblerHubEvents | transitionalEvents | { type: 'AI_WIZARD_CLOSED_BEFORE_COMPLETION'; payload: { step: string } } | { type: 'EXTERNAL_URL_UPDATE' }; const updateQueryStep = ( _context: unknown, _evt: unknown, { action }: { action: unknown } ) => { const { path } = getQuery() as { path: string }; const step = ( action as { step: string } ).step; const pathFragments = path.split( '/' ); // [0] '', [1] 'customize-store', [2] step slug [3] design-with-ai, assembler-hub path fragments if ( pathFragments[ 1 ] === 'customize-store' ) { if ( pathFragments[ 2 ] !== step ) { // this state machine is only concerned with [2], so we ignore changes to [3] // [1] is handled by router at root of wc-admin updateQueryString( {}, `/customize-store/${ step }` ); } } }; const redirectToWooHome = () => { window.location.href = getNewPath( {}, '/', {} ); }; const redirectToThemes = () => { window.location.href = addQueryArgs( 'themes.php' ); }; const markTaskComplete = async () => { return dispatch( OPTIONS_STORE_NAME ).updateOptions( { woocommerce_admin_customize_store_completed: 'yes', } ); }; const browserPopstateHandler = () => ( sendBack: Sender< { type: 'EXTERNAL_URL_UPDATE' } > ) => { const popstateHandler = () => { sendBack( { type: 'EXTERNAL_URL_UPDATE' } ); }; window.addEventListener( 'popstate', popstateHandler ); return () => { window.removeEventListener( 'popstate', popstateHandler ); }; }; export const machineActions = { updateQueryStep, redirectToWooHome, redirectToThemes, }; export const customizeStoreStateMachineActions = { ...introActions, ...machineActions, }; export const customizeStoreStateMachineServices = { ...introServices, ...transitionalServices, browserPopstateHandler, markTaskComplete, }; export const customizeStoreStateMachineDefinition = createMachine( { id: 'customizeStore', initial: 'navigate', predictableActionArguments: true, preserveActionOrder: true, schema: { context: {} as customizeStoreStateMachineContext, events: {} as customizeStoreStateMachineEvents, services: {} as { fetchThemeCards: { data: ThemeCard[] }; }, }, context: { intro: { hasErrors: false, themeCards: [] as ThemeCard[], activeTheme: '', activeThemeHasMods: false, customizeStoreTaskCompleted: false, }, } as customizeStoreStateMachineContext, invoke: { src: 'browserPopstateHandler', }, on: { EXTERNAL_URL_UPDATE: { target: 'navigate', }, AI_WIZARD_CLOSED_BEFORE_COMPLETION: { target: 'intro', actions: [ { type: 'updateQueryStep', step: 'intro' } ], }, }, states: { navigate: { always: [ { target: 'intro', cond: { type: 'hasStepInUrl', step: 'intro', }, }, { target: 'designWithAi', cond: { type: 'hasStepInUrl', step: 'design-with-ai', }, }, { target: 'assemblerHub', cond: { type: 'hasStepInUrl', step: 'assembler-hub', }, }, { target: 'transitionalScreen', cond: { type: 'hasStepInUrl', step: 'transitional', }, }, { target: 'intro', }, ], }, intro: { id: 'intro', initial: 'preIntro', states: { preIntro: { invoke: { src: 'fetchIntroData', onError: { actions: 'assignFetchIntroDataError', target: 'intro', }, onDone: { target: 'intro', actions: [ 'assignThemeCards', 'assignActiveThemeHasMods', 'assignCustomizeStoreCompleted', ], }, }, }, intro: { meta: { component: Intro, }, }, }, on: { CLICKED_ON_BREADCRUMB: { actions: 'redirectToWooHome', }, DESIGN_WITH_AI: { actions: [ 'recordTracksDesignWithAIClicked' ], target: 'designWithAi', }, SELECTED_NEW_THEME: { actions: [ 'recordTracksThemeSelected' ], target: 'appearanceTask', }, SELECTED_ACTIVE_THEME: { actions: [ 'recordTracksThemeSelected' ], target: 'appearanceTask', }, SELECTED_BROWSE_ALL_THEMES: { actions: [ 'recordTracksBrowseAllThemesClicked', 'redirectToThemes', ], }, }, }, designWithAi: { initial: 'preDesignWithAi', states: { preDesignWithAi: { always: { target: 'designWithAi', }, }, designWithAi: { meta: { component: DesignWithAi, }, entry: [ { type: 'updateQueryStep', step: 'design-with-ai' }, ], }, }, on: { THEME_SUGGESTED: { target: 'assemblerHub', }, }, }, assemblerHub: { initial: 'assemblerHub', states: { assemblerHub: { entry: [ { type: 'updateQueryStep', step: 'assembler-hub' }, ], meta: { component: AssemblerHub, }, }, postAssemblerHub: { invoke: { src: 'markTaskComplete', onDone: { target: 'waitForSitePreview', }, }, }, waitForSitePreview: { after: { // Wait for 5 seconds before redirecting to the transitional page. This is to ensure that the site preview image is refreshed. 5000: { target: '#customizeStore.transitionalScreen', }, }, }, }, on: { FINISH_CUSTOMIZATION: { // Pre-fetch the site preview image for the site for transitional page. actions: [ 'prefetchSitePreview' ], target: '.postAssemblerHub', }, GO_BACK_TO_DESIGN_WITH_AI: { target: 'designWithAi', }, }, }, transitionalScreen: { entry: [ { type: 'updateQueryStep', step: 'transitional' } ], meta: { component: Transitional, }, on: { GO_BACK_TO_HOME: { actions: 'redirectToWooHome', }, }, }, appearanceTask: {}, }, } ); export const CustomizeStoreController = ( { actionOverrides, servicesOverrides, }: { actionOverrides: Partial< typeof customizeStoreStateMachineActions >; servicesOverrides: Partial< typeof customizeStoreStateMachineServices >; } ) => { useFullScreen( [ 'woocommerce-customize-store' ] ); const augmentedStateMachine = useMemo( () => { return customizeStoreStateMachineDefinition.withConfig( { services: { ...customizeStoreStateMachineServices, ...servicesOverrides, }, actions: { ...customizeStoreStateMachineActions, ...actionOverrides, }, guards: { hasStepInUrl: ( _ctx, _evt, { cond }: { cond: unknown } ) => { const { path = '' } = getQuery() as { path: string }; const pathFragments = path.split( '/' ); return ( pathFragments[ 2 ] === // [0] '', [1] 'customize-store', [2] step slug ( cond as { step: string | undefined } ).step ); }, }, } ); }, [ actionOverrides, servicesOverrides ] ); const [ state, send, service ] = useMachine( augmentedStateMachine, { devTools: process.env.NODE_ENV === 'development', } ); // eslint-disable-next-line react-hooks/exhaustive-deps -- false positive due to function name match, this isn't from react std lib const currentNodeMeta = useSelector( service, ( currentState ) => findComponentMeta< CustomizeStoreComponentMeta >( currentState?.meta ?? undefined ) ); const [ CurrentComponent, setCurrentComponent ] = useState< CustomizeStoreComponent | null >( null ); useEffect( () => { if ( currentNodeMeta?.component ) { setCurrentComponent( () => currentNodeMeta?.component ); } }, [ CurrentComponent, currentNodeMeta?.component ] ); const currentNodeCssLabel = state.value instanceof Object ? Object.keys( state.value )[ 0 ] : state.value; return ( <>