[CYS - Core] Update the homepage with default patterns when the assembler is loaded (#43457)

CYS - Core: Setup the site with a default homepage when the assembler is loaded
This commit is contained in:
Luigi Teschio 2024-01-11 15:32:16 +01:00 committed by GitHub
parent 99e825df1f
commit c686f605e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 515 additions and 133 deletions

View File

@ -243,11 +243,13 @@ export const Layout = () => {
</div>
{ ! isEditorLoading &&
shouldTourBeShown &&
! showAiOfflineModal && (
( FlowType.AIOnline === context.flowType ||
FlowType.noAI === context.flowType ) && (
<OnboardingTour
skipTour={ skipTour }
takeTour={ takeTour }
onClose={ onClose }
flowType={ context.flowType }
{ ...onboardingTourProps }
/>
) }

View File

@ -10,6 +10,7 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
export * from './use-onboarding-tour';
import { FlowType } from '~/customize-store/types';
type OnboardingTourProps = {
onClose: () => void;
@ -17,18 +18,53 @@ type OnboardingTourProps = {
takeTour: () => void;
showWelcomeTour: boolean;
setIsResizeHandleVisible: ( isVisible: boolean ) => void;
flowType: FlowType.AIOnline | FlowType.noAI;
};
const getLabels = ( flowType: FlowType.AIOnline | FlowType.noAI ) => {
switch ( flowType ) {
case FlowType.AIOnline:
return {
heading: __(
'Welcome to your AI-generated store!',
'woocommerce'
),
descriptions: {
desktop: __(
'This is where you can start customizing the look and feel of your store, including adding your logo, and changing colors and layouts. Take a quick tour to discover whats possible.',
'woocommerce'
),
},
};
case FlowType.noAI:
return {
heading: __(
"Discover what's possible with the store designer",
'woocommerce'
),
descriptions: {
desktop: __(
"Start designing your store, including adding your logo, changing color schemes, and choosing layouts. Take a quick tour to discover what's possible.",
'woocommerce'
),
},
};
}
};
export const OnboardingTour = ( {
onClose,
skipTour,
takeTour,
flowType,
showWelcomeTour,
setIsResizeHandleVisible,
}: OnboardingTourProps ) => {
const [ placement, setPlacement ] =
useState< TourKitTypes.WooConfig[ 'placement' ] >( 'left' );
const { heading, descriptions } = getLabels( flowType );
if ( showWelcomeTour ) {
return (
<TourKit
@ -71,16 +107,8 @@ export const OnboardingTour = ( {
primaryButton: {
text: __( 'Take a tour', 'woocommerce' ),
},
descriptions: {
desktop: __(
"This is where you can start customizing the look and feel of your store, including adding your logo, and changing colors and layouts. Take a quick tour to discover what's possible.",
'woocommerce'
),
},
heading: __(
'Welcome to your AI-generated store!',
'woocommerce'
),
descriptions,
heading,
skipButton: {
isVisible: true,
},

View File

@ -9,6 +9,7 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import { OnboardingTour } from '../index';
import { FlowType } from '~/customize-store/types';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
jest.mock( '../../', () => ( {
@ -26,6 +27,7 @@ describe( 'OnboardingTour', () => {
takeTour: jest.Mock;
setShowWelcomeTour: jest.Mock;
showWelcomeTour: boolean;
flowType: FlowType.AIOnline | FlowType.noAI;
setIsResizeHandleVisible: ( isVisible: boolean ) => void;
};
@ -37,10 +39,11 @@ describe( 'OnboardingTour', () => {
setShowWelcomeTour: jest.fn(),
showWelcomeTour: true,
setIsResizeHandleVisible: jest.fn(),
flowType: FlowType.AIOnline,
};
} );
it( 'should render welcome tour', () => {
it( 'should render welcome tour mentioning the AI when the flowType is AIOnline', () => {
render( <OnboardingTour { ...props } /> );
expect(
@ -48,6 +51,16 @@ describe( 'OnboardingTour', () => {
).toBeInTheDocument();
} );
it( 'should render welcome tour not mentioning the AI when the flowType is AIOnline', () => {
render( <OnboardingTour { ...props } flowType={ FlowType.noAI } /> );
expect(
screen.getByText(
/Discover what's possible with the store designer/i
)
).toBeInTheDocument();
} );
it( 'should render step 1', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );

View File

@ -0,0 +1,71 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { resolveSelect, dispatch } from '@wordpress/data';
// @ts-ignore No types for this exist yet.
import { store as coreStore } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
/**
* Internal dependencies
*/
import {
patternsToNameMap,
getTemplatePatterns,
} from '../assembler-hub/hooks/use-home-templates';
import { setLogoWidth } from '../utils';
import { HOMEPAGE_TEMPLATES } from './homepageTemplates';
// Update the current theme template
export const updateTemplate = async ( {
homepageTemplateId,
}: {
homepageTemplateId: keyof typeof HOMEPAGE_TEMPLATES;
} ) => {
// @ts-ignore No types for this exist yet.
const { invalidateResolutionForStoreSelector } = dispatch( coreStore );
// Ensure that the patterns are up to date because we populate images and content in previous step.
invalidateResolutionForStoreSelector( 'getBlockPatterns' );
invalidateResolutionForStoreSelector( '__experimentalGetTemplateForLink' );
const patterns = ( await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).getBlockPatterns() ) as Pattern[];
const patternsByName = patternsToNameMap( patterns );
const homepageTemplate = getTemplatePatterns(
HOMEPAGE_TEMPLATES[ homepageTemplateId ].blocks,
patternsByName
);
let content = [ ...homepageTemplate ]
.filter( Boolean )
.map( ( pattern ) => pattern.content )
.join( '\n\n' );
// Replace the logo width with the default width.
content = setLogoWidth( content );
const currentTemplate = await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).__experimentalGetTemplateForLink( '/' );
// @ts-ignore No types for this exist yet.
const { saveEntityRecord } = dispatch( coreStore );
await saveEntityRecord(
'postType',
currentTemplate.type,
{
id: currentTemplate.id,
content,
},
{
throwOnError: true,
}
);
};

View File

@ -19,12 +19,8 @@ import { mergeBaseAndUserConfigs } from '@wordpress/edit-site/build-module/compo
import { designWithAiStateMachineContext } from './types';
import { FONT_PAIRINGS } from '../assembler-hub/sidebar/global-styles/font-pairing-variations/constants';
import { COLOR_PALETTES } from '../assembler-hub/sidebar/global-styles/color-palette-variations/constants';
import {
patternsToNameMap,
getTemplatePatterns,
} from '../assembler-hub/hooks/use-home-templates';
import { HOMEPAGE_TEMPLATES } from '../data/homepageTemplates';
import { setLogoWidth } from '../utils';
import { updateTemplate } from '../data/actions';
const { escalate } = actions;
@ -396,58 +392,6 @@ const updateGlobalStyles = async ( {
);
};
// Update the current theme template
const updateTemplate = async ( {
homepageTemplateId,
}: {
homepageTemplateId: keyof typeof HOMEPAGE_TEMPLATES;
} ) => {
// @ts-ignore No types for this exist yet.
const { invalidateResolutionForStoreSelector } = dispatch( coreStore );
// Ensure that the patterns are up to date because we populate images and content in previous step.
invalidateResolutionForStoreSelector( 'getBlockPatterns' );
invalidateResolutionForStoreSelector( '__experimentalGetTemplateForLink' );
const patterns = ( await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).getBlockPatterns() ) as Pattern[];
const patternsByName = patternsToNameMap( patterns );
const homepageTemplate = getTemplatePatterns(
HOMEPAGE_TEMPLATES[ homepageTemplateId ].blocks,
patternsByName
);
let content = [ ...homepageTemplate ]
.filter( Boolean )
.map( ( pattern ) => pattern.content )
.join( '\n\n' );
// Replace the logo width with the default width.
content = setLogoWidth( content );
const currentTemplate = await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).__experimentalGetTemplateForLink( '/' );
// @ts-ignore No types for this exist yet.
const { saveEntityRecord } = dispatch( coreStore );
await saveEntityRecord(
'postType',
currentTemplate.type,
{
id: currentTemplate.id,
content,
},
{
throwOnError: true,
}
);
};
export const assembleSite = async (
context: designWithAiStateMachineContext
) => {

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { getNewPath } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { attachIframeListeners, onIframeLoad } from '../utils';
const redirectToAssemblerHub = async () => {
const assemblerUrl = getNewPath( {}, '/customize-store/assembler-hub', {} );
const iframe = document.createElement( 'iframe' );
iframe.classList.add( 'cys-fullscreen-iframe' );
iframe.src = assemblerUrl;
const showIframe = () => {
if ( iframe.style.opacity === '1' ) {
// iframe is already visible
return;
}
const loader = document.getElementsByClassName(
'woocommerce-onboarding-loader'
);
if ( loader[ 0 ] ) {
( loader[ 0 ] as HTMLElement ).style.display = 'none';
}
iframe.style.opacity = '1';
};
iframe.onload = () => {
// Hide loading UI
attachIframeListeners( iframe );
onIframeLoad( showIframe );
// Ceiling wait time set to 60 seconds
setTimeout( showIframe, 60 * 1000 );
window.history?.pushState( {}, '', assemblerUrl );
};
document.body.appendChild( iframe );
};
export const actions = {
redirectToAssemblerHub,
};

View File

@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { useMachine, useSelector } from '@xstate/react';
import { AnyInterpreter, Sender } from 'xstate';
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { customizeStoreStateMachineEvents } from '..';
import {
CustomizeStoreComponent,
customizeStoreStateMachineContext,
} from '../types';
import { designWithNoAiStateMachineDefinition } from './state-machine';
import { findComponentMeta } from '~/utils/xstate/find-component';
import { AssembleHubLoader } from '../design-with-ai/pages';
export type DesignWithoutAiComponent = typeof AssembleHubLoader;
export type DesignWithoutAiComponentMeta = {
component: DesignWithoutAiComponent;
};
export const DesignWithNoAiController = ( {
parentMachine,
}: {
parentMachine?: AnyInterpreter;
sendEventToParent?: Sender< customizeStoreStateMachineEvents >;
parentContext?: customizeStoreStateMachineContext;
} ) => {
const [ , , service ] = useMachine( designWithNoAiStateMachineDefinition, {
devTools: process.env.NODE_ENV === 'development',
parent: parentMachine,
} );
// 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< DesignWithoutAiComponentMeta >(
currentState?.meta ?? undefined
)
);
const [ CurrentComponent, setCurrentComponent ] =
useState< DesignWithoutAiComponent | null >( null );
useEffect( () => {
if ( currentNodeMeta?.component ) {
setCurrentComponent( () => currentNodeMeta?.component );
}
}, [ CurrentComponent, currentNodeMeta?.component ] );
return (
<>
<div className={ `woocommerce-design-without-ai__container` }>
{ CurrentComponent ? <CurrentComponent /> : <div /> }
</div>
</>
);
};
//loader should send event 'THEME_SUGGESTED' when it's done
export const DesignWithoutAi: CustomizeStoreComponent = ( {
parentMachine,
context,
} ) => {
return (
<>
<DesignWithNoAiController
parentMachine={ parentMachine }
parentContext={ context }
/>
</>
);
};

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { Sender } from 'xstate';
/**
* Internal dependencies
*/
import { updateTemplate } from '../data/actions';
import { HOMEPAGE_TEMPLATES } from '../data/homepageTemplates';
const assembleSite = async () => {
await updateTemplate( {
homepageTemplateId: 'template1' as keyof typeof HOMEPAGE_TEMPLATES,
} );
};
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 services = {
assembleSite,
browserPopstateHandler,
};

View File

@ -0,0 +1,111 @@
/**
* External dependencies
*/
import { EventObject, createMachine } from 'xstate';
import { getQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { AssembleHubLoader } from '../design-with-ai/pages';
import { FlowType } from '../types';
import { DesignWithoutAIStateMachineContext } from './types';
import { services } from './services';
import { actions } from './actions';
export const hasStepInUrl = (
_ctx: unknown,
_evt: unknown,
{ cond }: { cond: unknown }
) => {
const { path = '' } = getQuery() as { path: string };
const pathFragments = path.split( '/' );
return (
pathFragments[ 2 ] === // [0] '', [1] 'customize-store', [2] design step slug
( cond as { step: string | undefined } ).step
);
};
export const designWithNoAiStateMachineDefinition = createMachine(
{
id: 'designWithoutAI',
predictableActionArguments: true,
preserveActionOrder: true,
schema: {
context: {} as DesignWithoutAIStateMachineContext,
events: {} as EventObject,
},
invoke: {
src: 'browserPopstateHandler',
},
on: {
EXTERNAL_URL_UPDATE: {
target: 'navigate',
},
},
context: {
startLoadingTime: null,
flowType: FlowType.noAI,
apiCallLoader: {
hasErrors: false,
},
},
initial: 'navigate',
states: {
navigate: {
always: [
{
cond: {
type: 'hasStepInUrl',
step: 'design',
},
target: 'preAssembleSite',
},
],
},
preAssembleSite: {
type: 'parallel',
states: {
assembleSite: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'assembleSite',
onDone: {
target: 'done',
},
onError: {
actions: [ 'assignAPICallLoaderError' ],
},
},
},
done: {
type: 'final',
},
},
onDone: {
target: '#designWithoutAI.showAssembleHub',
},
},
},
},
showAssembleHub: {
meta: {
component: AssembleHubLoader,
},
entry: [ 'redirectToAssemblerHub' ],
type: 'final',
},
},
},
{
actions,
services,
guards: {
hasStepInUrl,
},
}
);

View File

@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import { FlowType } from '../types';
export type DesignWithoutAIStateMachineContext = {
startLoadingTime: number | null;
apiCallLoader: {
hasErrors: boolean;
};
flowType: FlowType.noAI;
};

View File

@ -28,6 +28,8 @@ import {
actions as introActions,
} from './intro';
import { DesignWithAi, events as designWithAiEvents } from './design-with-ai';
import { DesignWithoutAi } from './design-without-ai';
import { AssemblerHub, events as assemblerHubEvents } from './assembler-hub';
import {
events as transitionalEvents,
@ -193,6 +195,13 @@ export const customizeStoreStateMachineDefinition = createMachine( {
step: 'design-with-ai',
},
},
{
target: 'designWithoutAi',
cond: {
type: 'hasStepInUrl',
step: 'design',
},
},
{
target: 'assemblerHub',
cond: {
@ -222,64 +231,59 @@ export const customizeStoreStateMachineDefinition = createMachine( {
// eslint-disable-next-line xstate/prefer-always
'': [
{
target: 'intro',
target: 'fetchIntroData',
cond: 'isNotWooExpress',
actions: 'assignNoAI',
},
{
target: 'preIntro',
target: 'checkAiStatus',
cond: 'isWooExpress',
},
],
},
},
preIntro: {
type: 'parallel',
checkAiStatus: {
initial: 'pending',
states: {
checkAiStatus: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'fetchAiStatus',
onDone: {
actions: 'assignAiStatus',
target: 'success',
},
onError: {
actions: 'assignAiOffline',
target: 'success',
},
},
pending: {
invoke: {
src: 'fetchAiStatus',
onDone: {
actions: 'assignAiStatus',
target: 'success',
},
onError: {
actions: 'assignAiOffline',
target: 'success',
},
success: { type: 'final' },
},
},
fetchIntroData: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'fetchIntroData',
onError: {
actions:
'assignFetchIntroDataError',
target: 'success',
},
onDone: {
target: 'success',
actions: [
'assignThemeData',
'assignActiveThemeHasMods',
'assignCustomizeStoreCompleted',
'assignCurrentThemeIsAiGenerated',
],
},
},
success: { type: 'final' },
},
onDone: 'fetchIntroData',
},
fetchIntroData: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'fetchIntroData',
onError: {
actions: 'assignFetchIntroDataError',
target: 'success',
},
onDone: {
target: 'success',
actions: [
'assignThemeData',
'assignActiveThemeHasMods',
'assignCustomizeStoreCompleted',
'assignCurrentThemeIsAiGenerated',
],
},
success: { type: 'final' },
},
},
success: { type: 'final' },
},
onDone: 'intro',
},
@ -297,6 +301,10 @@ export const customizeStoreStateMachineDefinition = createMachine( {
actions: [ 'recordTracksDesignWithAIClicked' ],
target: 'designWithAi',
},
DESIGN_WITHOUT_AI: {
actions: [ 'recordTracksDesignWithAIClicked' ],
target: 'designWithoutAi',
},
SELECTED_NEW_THEME: {
actions: [ 'recordTracksThemeSelected' ],
target: 'appearanceTask',
@ -313,6 +321,22 @@ export const customizeStoreStateMachineDefinition = createMachine( {
},
},
},
designWithoutAi: {
initial: 'preDesignWithoutAi',
states: {
preDesignWithoutAi: {
always: {
target: 'designWithoutAi',
},
},
designWithoutAi: {
entry: [ { type: 'updateQueryStep', step: 'design' } ],
meta: {
component: DesignWithoutAi,
},
},
},
},
designWithAi: {
initial: 'preDesignWithAi',
states: {

View File

@ -30,7 +30,7 @@ import {
DefaultBanner,
ExistingAiThemeBanner,
ExistingThemeBanner,
CoreBanner,
NoAIBanner,
} from './intro-banners';
export type events =
@ -39,7 +39,8 @@ export type events =
| { type: 'CLICKED_ON_BREADCRUMB' }
| { type: 'SELECTED_BROWSE_ALL_THEMES' }
| { type: 'SELECTED_ACTIVE_THEME'; payload: { theme: string } }
| { type: 'SELECTED_NEW_THEME'; payload: { theme: string } };
| { type: 'SELECTED_NEW_THEME'; payload: { theme: string } }
| { type: 'DESIGN_WITHOUT_AI' };
export * as actions from './actions';
export * as services from './services';
@ -52,7 +53,7 @@ const BANNER_COMPONENTS = {
'jetpack-offline': JetpackOfflineBanner,
'existing-ai-theme': ExistingAiThemeBanner,
'existing-theme': ExistingThemeBanner,
[ FlowType.noAI ]: CoreBanner,
[ FlowType.noAI ]: NoAIBanner,
default: DefaultBanner,
};

View File

@ -21,6 +21,7 @@ export const BaseIntroBanner = ( {
bannerTitle,
bannerText,
bannerClass,
showAIDisclaimer,
buttonIsLink,
bannerButtonOnClick,
bannerButtonText,
@ -30,6 +31,7 @@ export const BaseIntroBanner = ( {
bannerTitle: string;
bannerText: string;
bannerClass: string;
showAIDisclaimer: boolean;
buttonIsLink?: boolean;
bannerButtonOnClick?: () => void;
bannerButtonText?: string;
@ -58,23 +60,25 @@ export const BaseIntroBanner = ( {
</Button>
) }
{ secondaryButton }
<p className="ai-disclaimer">
{ interpolateComponents( {
mixedString: __(
'Powered by experimental AI. {{link}}Learn more{{/link}}',
'woocommerce'
),
components: {
link: (
<Link
href="https://automattic.com/ai-guidelines"
target="_blank"
type="external"
/>
{ showAIDisclaimer && (
<p className="ai-disclaimer">
{ interpolateComponents( {
mixedString: __(
'Powered by experimental AI. {{link}}Learn more{{/link}}',
'woocommerce'
),
},
} ) }
</p>
components: {
link: (
<Link
href="https://automattic.com/ai-guidelines"
target="_blank"
type="external"
/>
),
},
} ) }
</p>
) }
</div>
{ children }
</div>
@ -95,6 +99,7 @@ export const NetworkOfflineBanner = () => {
) }
bannerClass="offline-banner"
bannerButtonOnClick={ () => {} }
showAIDisclaimer={ true }
/>
);
};
@ -122,6 +127,7 @@ export const JetpackOfflineBanner = ( {
} );
} }
bannerButtonText={ __( 'Find out how', 'woocommerce' ) }
showAIDisclaimer={ true }
/>
);
};
@ -147,6 +153,7 @@ export const ExistingThemeBanner = ( {
setOpenDesignChangeWarningModal( true );
} }
bannerButtonText={ __( 'Design with AI', 'woocommerce' ) }
showAIDisclaimer={ true }
/>
);
};
@ -174,6 +181,7 @@ export const DefaultBanner = ( {
} );
} }
bannerButtonText={ __( 'Design with AI', 'woocommerce' ) }
showAIDisclaimer={ true }
/>
);
};
@ -199,11 +207,16 @@ export const ThemeHasModsBanner = ( {
setOpenDesignChangeWarningModal( true );
} }
bannerButtonText={ __( 'Design with AI', 'woocommerce' ) }
showAIDisclaimer={ true }
/>
);
};
export const CoreBanner = () => {
export const NoAIBanner = ( {
sendEvent,
}: {
sendEvent: React.ComponentProps< typeof Intro >[ 'sendEvent' ];
} ) => {
return (
<BaseIntroBanner
bannerTitle={ __( 'Design your own', 'woocommerce' ) }
@ -211,8 +224,14 @@ export const CoreBanner = () => {
'Quickly create a beautiful store using our built-in store designer. Choose your layout, select a style, and much more.',
'woocommerce'
) }
bannerClass="core-banner"
bannerButtonOnClick={ () => {} }
bannerClass="no-ai-banner"
bannerButtonText={ __( 'Start designing', 'woocommerce' ) }
bannerButtonOnClick={ () => {
sendEvent( {
type: 'DESIGN_WITHOUT_AI',
} );
} }
showAIDisclaimer={ false }
/>
);
};
@ -260,6 +279,7 @@ export const ExistingAiThemeBanner = ( {
} }
bannerButtonText={ __( 'Customize', 'woocommerce' ) }
secondaryButton={ secondaryButton }
showAIDisclaimer={ true }
>
<div className={ 'woocommerce-block-preview-container' }>
<div className="iframe-container">

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
[CYS - Core] Update the homepage with default patterns when the assembler is loaded.