Add customize store transitional screen (#40122)

* Add CYS transitional screen

* Add changefile(s) from automation for the following project(s): woocommerce

* Add tests

* Update preview loading style and add requeue param

* Fix visible logic

* Fix img size

* Update comments

* Fix lint

* Pre-fetch image and wait a 5s before redirecting to transitional page after clicking on done button

* Remove unneed overflow

* Move pre-fetch logic to xstate and use spinner for button loading state

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chi-Hsuan Huang 2023-09-15 12:01:02 +08:00 committed by GitHub
parent 95d7a6b86d
commit 80eaece265
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 637 additions and 6 deletions

View File

@ -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 = () => {
<Button
variant="primary"
onClick={ () => {
setIsResolving( true );
sendEvent( 'FINISH_CUSTOMIZATION' );
} }
className="edit-site-save-hub__button"
// @ts-ignore No types for this exist yet.
__next40pxDefaultSize
>
{ __( 'Done', 'woocommerce' ) }
{ isResolving ? <Spinner /> : __( 'Done', 'woocommerce' ) }
</Button>
);
}

View File

@ -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 >(

View File

@ -19,6 +19,7 @@
body.woocommerce-customize-store.js.is-fullscreen-mode {
margin-top: 0 !important;
height: 100%;
}
.woocommerce-cys-layout {

View File

@ -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 (
<div className="woocommerce-customize-store__transitional">
<SiteHub
as={ motion.div }
variants={ {
view: { x: 0 },
} }
isTransparent={ false }
className="edit-site-layout__hub"
/>
<div className="woocommerce-customize-store__transitional-content">
<h1 className="woocommerce-customize-store__transitional-heading">
{ __( 'Your store looks great!', 'woocommerce' ) }
</h1>
<h2 className="woocommerce-customize-store__transitional-subheading">
{ __(
"Your store is a reflection of your unique style and personality, and we're thrilled to see it come to life.",
'woocommerce'
) }
</h2>
<Button
className="woocommerce-customize-store__transitional-preview-button"
variant="primary"
href={ homeUrl }
target="_blank"
>
{ __( 'Preview store', 'woocommerce' ) }
</Button>
<div className="woocommerce-customize-store__transitional-site-img-container">
<MShotsImage
url={ homeUrl }
alt={ __( 'Your store screenshot', 'woocommerce' ) }
aria-labelledby={ __(
'Your store screenshot',
'woocommerce'
) }
options={ PREVIEW_IMAGE_OPTION }
/>
</div>
<div className="woocommerce-customize-store__transitional-actions">
<div className="woocommerce-customize-store__transitional-action">
<h3>
{ __( 'Fine-tune your design', 'woocommerce' ) }
</h3>
<p>
{ __(
'Head to the Editor to change your images and text, add more pages, and make any further customizations.',
'woocommerce'
) }
</p>
<Button
variant="tertiary"
href={ `${ ADMIN_URL }site-editor.php` }
>
{ __( 'Go to the Editor', 'woocommerce' ) }
</Button>
</div>
<div className="woocommerce-customize-store__transitional-action">
<h3>
{ __(
'Continue setting up your store',
'woocommerce'
) }
</h3>
<p>
{ __(
'Go back to the Home screen to complete your store setup and start selling',
'woocommerce'
) }
</p>
<Button
variant="tertiary"
onClick={ () =>
sendEvent( {
type: 'GO_BACK_TO_HOME',
} )
}
>
{ __( 'Back to Home', 'woocommerce' ) }
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@ -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 ? (
<div
className={ className }
style={ style }
aria-labelledby={ labelledby }
/>
) : (
<img
{ ...{ className, style, src, alt } }
aria-labelledby={ labelledby }
alt={ alt }
/>
);
};
export default MShotsImage;

View File

@ -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
} );
};

View File

@ -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;
}
}

View File

@ -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 <img alt="preview-img" />;
},
} ) );
jest.mock( '../../assembler-hub/site-hub', () => ( {
__esModule: true,
SiteHub: () => {
return <div />;
},
} ) );
describe( 'Transitional', () => {
let props: {
sendEvent: jest.Mock;
};
beforeEach( () => {
props = {
sendEvent: jest.fn(),
};
} );
it( 'should render Transitional page', () => {
// @ts-ignore
render( <Transitional { ...props } /> );
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( <Transitional { ...props } /> );
screen
.getByRole( 'button', {
name: /Back to Home/i,
} )
.click();
expect( props.sendEvent ).toHaveBeenCalledWith( {
type: 'GO_BACK_TO_HOME',
} );
} );
} );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add customize store transitional screen