diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx
index 8b8a35343ea..b380ce609b8 100644
--- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx
+++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx
@@ -4,11 +4,15 @@
/**
* External dependencies
*/
-import { useContext, useEffect } from '@wordpress/element';
+import { useContext, useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation';
import { useSelect, useDispatch } from '@wordpress/data';
-// @ts-ignore No types for this exist yet.
-import { Button, __experimentalHStack as HStack } from '@wordpress/components';
+import {
+ // @ts-ignore No types for this exist yet.
+ __experimentalHStack as HStack,
+ Button,
+ Spinner,
+} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
// @ts-ignore No types for this exist yet.
import { store as coreStore } from '@wordpress/core-data';
@@ -35,6 +39,7 @@ export const SaveHub = () => {
const saveNoticeId = 'site-edit-save-notice';
const urlParams = useQuery();
const { sendEvent } = useContext( CustomizeStoreContext );
+ const [ isResolving, setIsResolving ] = useState< boolean >( false );
// @ts-ignore No types for this exist yet.
const { __unstableMarkLastChangeAsPersistent } =
@@ -180,13 +185,14 @@ export const SaveHub = () => {
);
}
diff --git a/plugins/woocommerce-admin/client/customize-store/index.tsx b/plugins/woocommerce-admin/client/customize-store/index.tsx
index 7ce22b690e6..2c16fdad90e 100644
--- a/plugins/woocommerce-admin/client/customize-store/index.tsx
+++ b/plugins/woocommerce-admin/client/customize-store/index.tsx
@@ -18,6 +18,11 @@ import {
} 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,
@@ -31,6 +36,7 @@ export type customizeStoreStateMachineEvents =
| introEvents
| designWithAiEvents
| assemblerHubEvents
+ | transitionalEvents
| { type: 'AI_WIZARD_CLOSED_BEFORE_COMPLETION'; payload: { step: string } }
| { type: 'EXTERNAL_URL_UPDATE' };
@@ -73,6 +79,7 @@ export const customizeStoreStateMachineActions = {
export const customizeStoreStateMachineServices = {
...introServices,
+ ...transitionalServices,
browserPopstateHandler,
};
export const customizeStoreStateMachineDefinition = createMachine( {
@@ -205,16 +212,36 @@ export const customizeStoreStateMachineDefinition = createMachine( {
component: AssemblerHub,
},
},
+ postAssemblerHub: {
+ 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: {
- target: 'backToHomescreen',
+ // 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: {
+ meta: {
+ component: Transitional,
+ },
+ on: {
+ GO_BACK_TO_HOME: {
+ target: 'backToHomescreen',
+ },
+ },
+ },
backToHomescreen: {},
appearanceTask: {},
},
@@ -255,7 +282,6 @@ export const CustomizeStoreController = ( {
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 >(
diff --git a/plugins/woocommerce-admin/client/customize-store/style.scss b/plugins/woocommerce-admin/client/customize-store/style.scss
index 4f76178c6e3..b6fb0524d48 100644
--- a/plugins/woocommerce-admin/client/customize-store/style.scss
+++ b/plugins/woocommerce-admin/client/customize-store/style.scss
@@ -19,6 +19,7 @@
body.woocommerce-customize-store.js.is-fullscreen-mode {
margin-top: 0 !important;
+ height: 100%;
}
.woocommerce-cys-layout {
diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/index.tsx b/plugins/woocommerce-admin/client/customize-store/transitional/index.tsx
new file mode 100644
index 00000000000..b95976c1181
--- /dev/null
+++ b/plugins/woocommerce-admin/client/customize-store/transitional/index.tsx
@@ -0,0 +1,126 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { getSetting } from '@woocommerce/settings';
+import {
+ Button,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore No types for this exist yet.
+ __unstableMotion as motion,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { SiteHub } from '../assembler-hub/site-hub';
+import { MShotsImage } from './mshots-image';
+import { ADMIN_URL } from '~/utils/admin-settings';
+import './style.scss';
+export * as services from './services';
+
+export type events = { type: 'GO_BACK_TO_HOME' };
+export const PREVIEW_IMAGE_OPTION = {
+ vpw: 1200,
+ vph: 742,
+ w: 588,
+ h: 363.58,
+ requeue: true,
+};
+
+export const Transitional = ( {
+ sendEvent,
+}: {
+ sendEvent: ( event: events ) => void;
+} ) => {
+ const homeUrl: string = getSetting( 'homeUrl', '' );
+
+ return (
+
+
+
+
+
+ { __( 'Your store looks great!', 'woocommerce' ) }
+
+
+ { __(
+ "Your store is a reflection of your unique style and personality, and we're thrilled to see it come to life.",
+ 'woocommerce'
+ ) }
+
+
+
+
+
+
+
+
+
+ { __( 'Fine-tune your design', 'woocommerce' ) }
+
+
+ { __(
+ 'Head to the Editor to change your images and text, add more pages, and make any further customizations.',
+ 'woocommerce'
+ ) }
+
+
+
+
+
+
+ { __(
+ 'Continue setting up your store',
+ 'woocommerce'
+ ) }
+
+
+ { __(
+ 'Go back to the Home screen to complete your store setup and start selling',
+ 'woocommerce'
+ ) }
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/mshots-image.tsx b/plugins/woocommerce-admin/client/customize-store/transitional/mshots-image.tsx
new file mode 100644
index 00000000000..c6a4da6b4a1
--- /dev/null
+++ b/plugins/woocommerce-admin/client/customize-store/transitional/mshots-image.tsx
@@ -0,0 +1,215 @@
+// See https://github.com/Automattic/wp-calypso/blob/6923bed938911a931e722a1efa6fcbbf942677a9/packages/onboarding/src/mshots-image/index.tsx
+// TODO: @automattic/onboarding is not published to npm, so we can't use it here. We'll need to add the "requeue" option to MShotsOptions and we could remove this when it's published.
+/**
+ * External dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+import classnames from 'classnames';
+import { useState, useEffect, useRef } from 'react';
+
+interface MShotsImageProps {
+ url: string;
+ alt: string;
+ 'aria-labelledby': string;
+ options: MShotsOptions;
+ scrollable?: boolean;
+}
+
+export type MShotsOptions = {
+ vpw: number;
+ vph: number;
+ w: number;
+ h?: number;
+ screen_height?: number;
+ format?: 'png' | 'jpeg';
+ requeue?: boolean;
+};
+
+// eslint-disable-next-line no-console
+const debug = console.debug;
+
+export function getMshotsUrl(
+ targetUrl: string,
+ options: MShotsOptions,
+ count = 0
+): string {
+ const mshotsUrl = 'https://s0.wp.com/mshots/v1/';
+ const mshotsRequest = addQueryArgs(
+ mshotsUrl + encodeURIComponent( targetUrl ),
+ {
+ ...options,
+ count,
+ }
+ );
+ return mshotsRequest;
+}
+
+const MAXTRIES = 10;
+
+// This custom react hook returns undefined while the image is loading and
+// a HTMLImageElement (i.e. the class you get from `new Image()`) once loading
+// is complete.
+//
+// It also triggers a re-render (via setState()) when the value changes, so just
+// check if it's truthy and then treat it like any other Image.
+//
+// Note the loading may occur immediately and synchronously if the image is
+// already or may take up to several seconds if mshots has to generate and cache
+// new images.
+//
+// The calling code doesn't need to worry about the details except that you'll
+// want some sort of loading display.
+//
+// Inspired by https://stackoverflow.com/a/60458593
+const useMshotsImg = (
+ src: string,
+ options: MShotsOptions
+): HTMLImageElement | undefined => {
+ const [ loadedImg, setLoadedImg ] = useState< HTMLImageElement >();
+ const [ count, setCount ] = useState( 0 );
+ const previousSrc = useRef( src );
+
+ const imgRef = useRef< HTMLImageElement >();
+ const timeoutIdRef = useRef< number >();
+
+ const previousImg = useRef< HTMLImageElement >();
+ const previousOptions = useRef< MShotsOptions >();
+ // Oddly, we need to assign to current here after ref creation in order to
+ // pass the equivalence check and avoid a spurious reset
+ previousOptions.current = options;
+
+ // Note: Mshots doesn't care about the "count" param, but it is important
+ // to browser caching. Getting this wrong looks like the url resolving
+ // before the image is ready.
+ useEffect( () => {
+ // If there's been a "props" change we need to reset everything:
+ if (
+ options !== previousOptions.current ||
+ ( src !== previousSrc.current && imgRef.current )
+ ) {
+ // Make sure an old image can't trigger a spurious state update
+ debug( 'resetting mShotsUrl request' );
+ if ( src !== previousSrc.current ) {
+ debug( 'src changed\nfrom', previousSrc.current, '\nto', src );
+ }
+ if ( options !== previousOptions.current ) {
+ debug(
+ 'options changed\nfrom',
+ previousOptions.current,
+ '\nto',
+ options
+ );
+ }
+ if ( previousImg.current && previousImg.current.onload ) {
+ previousImg.current.onload = null;
+ if ( timeoutIdRef.current ) {
+ clearTimeout( timeoutIdRef.current );
+ timeoutIdRef.current = undefined;
+ }
+ }
+
+ setLoadedImg( undefined );
+ setCount( 0 );
+ previousImg.current = imgRef.current;
+
+ previousOptions.current = options;
+ previousSrc.current = src;
+ }
+
+ const srcUrl = getMshotsUrl( src, options, count );
+ const newImage = new Image();
+ newImage.onload = () => {
+ // Detect default image (Don't request a 400x300 image).
+ //
+ // If this turns out to be a problem, it might help to know that the
+ // http request status for the default is a 307. Unfortunately we
+ // don't get the request through an img element so we'd need to
+ // take a completely different approach using ajax.
+ if (
+ newImage.naturalWidth !== 400 ||
+ newImage.naturalHeight !== 300
+ ) {
+ // Note we're using the naked object here, not the ref, because
+ // this is the callback on the image itself. We'd never want
+ // the image to finish loading and set some other image.
+ setLoadedImg( newImage );
+ } else if ( count < MAXTRIES ) {
+ // Only refresh 10 times
+ // Triggers a target.src change with increasing timeouts
+ timeoutIdRef.current = window.setTimeout(
+ () => setCount( ( _count ) => _count + 1 ),
+ count * 500
+ );
+ }
+ };
+ newImage.src = srcUrl;
+ imgRef.current = newImage;
+
+ return () => {
+ if ( imgRef.current && imgRef.current.onload ) {
+ imgRef.current.onload = null;
+ }
+ clearTimeout( timeoutIdRef.current );
+ };
+ }, [ src, count, options ] );
+
+ return loadedImg;
+};
+
+// For hover-scroll, we use a div with a background image (rather than an img element)
+// in order to use transitions between `top` and `bottom` on the
+// `background-position` property.
+// The "normal" top & bottom properties are problematic individually because we
+// don't know how big the images will be, and using both gets the
+// right positions but with no transition (as they're different properties).
+export const MShotsImage = ( {
+ url,
+ 'aria-labelledby': labelledby,
+ alt,
+ options,
+ scrollable = false,
+}: MShotsImageProps ) => {
+ const maybeImage = useMshotsImg( url, options );
+ const src: string = maybeImage?.src || '';
+ const visible = !! src;
+ const backgroundImage = maybeImage?.src && `url( ${ maybeImage?.src } )`;
+
+ const animationScrollSpeedInPixelsPerSecond = 400;
+ const animationDuration =
+ ( maybeImage?.naturalHeight || 600 ) /
+ animationScrollSpeedInPixelsPerSecond;
+
+ const scrollableStyles = {
+ backgroundImage,
+ transition: `background-position ${ animationDuration }s`,
+ };
+
+ const style = {
+ ...( scrollable ? scrollableStyles : {} ),
+ };
+
+ const className = classnames(
+ 'mshots-image__container',
+ scrollable && 'hover-scroll',
+ visible ? 'mshots-image-visible' : 'mshots-image__loader'
+ );
+
+ // The "! visible" here is only to dodge a particularly specific css
+ // rule effecting the placeholder while loading static images:
+ // '.design-picker .design-picker__image-frame img { ..., height: auto }'
+ return scrollable || ! visible ? (
+
+ ) : (
+
+ );
+};
+
+export default MShotsImage;
diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/services.tsx b/plugins/woocommerce-admin/client/customize-store/transitional/services.tsx
new file mode 100644
index 00000000000..257d99d9914
--- /dev/null
+++ b/plugins/woocommerce-admin/client/customize-store/transitional/services.tsx
@@ -0,0 +1,19 @@
+/**
+ * External dependencies
+ */
+import { getSetting } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import { getMshotsUrl } from './mshots-image';
+import { PREVIEW_IMAGE_OPTION } from './';
+
+export const fetchSitePreviewImage = async () => {
+ const homeUrl: string = getSetting( 'homeUrl', '' );
+ return window
+ .fetch( getMshotsUrl( homeUrl, PREVIEW_IMAGE_OPTION ) )
+ .catch( () => {
+ // Ignore errors
+ } );
+};
diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/style.scss b/plugins/woocommerce-admin/client/customize-store/transitional/style.scss
new file mode 100644
index 00000000000..2fc1c331e6e
--- /dev/null
+++ b/plugins/woocommerce-admin/client/customize-store/transitional/style.scss
@@ -0,0 +1,154 @@
+.woocommerce-customize-store__transitional {
+ display: flex;
+ flex-direction: column;
+ width: 100vw;
+ height: 100vh;
+
+ .edit-site-site-hub.edit-site-layout__hub {
+ height: 64px;
+ padding: 16px;
+ gap: 12px;
+ width: 100%;
+ position: initial;
+
+ .edit-site-site-hub__view-mode-toggle-container,
+ .edit-site-site-icon__image,
+ svg {
+ height: 32px;
+ width: 32px;
+ background: transparent;
+ }
+
+ .edit-site-site-hub__site-title {
+ margin-left: 12px;
+ color: $gray-900;
+ font-size: 13px;
+ font-style: normal;
+ font-weight: 500;
+ line-height: 20px; /* 153.846% */
+ }
+ }
+
+ .woocommerce-customize-store__transitional-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 40px;
+ }
+
+ .woocommerce-customize-store__transitional-heading {
+ color: $gray-900;
+ text-align: center;
+ font-feature-settings: 'clig' off, 'liga' off;
+ font-size: 32px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 60px; /* 187.5% */
+ letter-spacing: -0.32px;
+ margin: 0;
+ }
+
+ .woocommerce-customize-store__transitional-subheading {
+ color: $gray-700;
+ text-align: center;
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 24px; /* 150% */
+ letter-spacing: -0.1px;
+ margin: 4px 0 0;
+ width: 560px;
+ }
+
+ .woocommerce-customize-store__transitional-preview-button {
+ padding: 8px 16px;
+ height: 40px;
+ margin: 20px 0 0;
+ }
+
+ .woocommerce-customize-store__transitional-site-img-container {
+ width: 600px;
+ height: 371px;
+ border-radius: 16px;
+ background: #f6f7f7;
+ box-shadow: 0 6px 6px 0 rgba(0, 0, 0, 0.02), 0 13px 10px 0 rgba(0, 0, 0, 0.03), 0 15px 20px 0 rgba(0, 0, 0, 0.04);
+ margin-top: 50px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .mshots-image__loader {
+ width: 600px;
+ height: 371px;
+ border-radius: 16px;
+ }
+
+ img {
+ border-radius: 16px;
+ }
+ }
+
+ .woocommerce-customize-store__transitional-actions {
+ display: flex;
+ flex-direction: row;
+ margin-top: 50px;
+ gap: 40px;
+
+ .woocommerce-customize-store__transitional-action {
+ display: flex;
+ flex-direction: column;
+ width: 280px;
+
+ h3 {
+ color: $gray-900;
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 500;
+ line-height: 24px; /* 150% */
+ margin: 0;
+ }
+
+ p {
+ margin: 5px 0 0;
+ color: $gray-700;
+ font-size: 13px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 16px; /* 123.077% */
+ height: 48px;
+ }
+
+ .components-button {
+ margin-top: 16px;
+ padding: 0;
+ margin-left: 0;
+ height: 20px;
+ width: fit-content;
+
+ &:hover {
+ background: transparent;
+ }
+ }
+ }
+ }
+}
+
+
+// mshots component
+.mshots-image__loader {
+ @include placeholder();
+}
+
+.mshots-image-visible {
+ animation: fadein 300ms;
+}
+
+@keyframes fadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx b/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx
new file mode 100644
index 00000000000..f526c8a1159
--- /dev/null
+++ b/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx
@@ -0,0 +1,80 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { Transitional } from '../index';
+
+jest.mock( '../mshots-image', () => ( {
+ __esModule: true,
+ MShotsImage: () => {
+ return ;
+ },
+} ) );
+
+jest.mock( '../../assembler-hub/site-hub', () => ( {
+ __esModule: true,
+ SiteHub: () => {
+ return ;
+ },
+} ) );
+
+describe( 'Transitional', () => {
+ let props: {
+ sendEvent: jest.Mock;
+ };
+
+ beforeEach( () => {
+ props = {
+ sendEvent: jest.fn(),
+ };
+ } );
+
+ it( 'should render Transitional page', () => {
+ // @ts-ignore
+ render( );
+
+ expect(
+ screen.getByText( /Your store looks great!/i )
+ ).toBeInTheDocument();
+
+ expect( screen.getByRole( 'img' ) ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole( 'link', {
+ name: /Preview store/i,
+ } )
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole( 'link', {
+ name: /Go to the Editor/i,
+ } )
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole( 'button', {
+ name: /Back to Home/i,
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should send GO_BACK_TO_HOME event when clicking on "Back to Home" button', () => {
+ // @ts-ignore
+ render( );
+
+ screen
+ .getByRole( 'button', {
+ name: /Back to Home/i,
+ } )
+ .click();
+
+ expect( props.sendEvent ).toHaveBeenCalledWith( {
+ type: 'GO_BACK_TO_HOME',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/changelog/add-cys-transitional-screen b/plugins/woocommerce/changelog/add-cys-transitional-screen
new file mode 100644
index 00000000000..91edc6d1698
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-cys-transitional-screen
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add customize store transitional screen