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