diff --git a/client/assets/images/onboarding/migrations/survey/wordpress-half-logo.png b/client/assets/images/onboarding/migrations/survey/wordpress-half-logo.png new file mode 100644 index 0000000000000..7644a796543d0 Binary files /dev/null and b/client/assets/images/onboarding/migrations/survey/wordpress-half-logo.png differ diff --git a/client/landing/stepper/declarative-flow/internals/components/survery-manager/index.tsx b/client/landing/stepper/declarative-flow/internals/components/survery-manager/index.tsx new file mode 100644 index 0000000000000..6a6c87d75f00e --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/components/survery-manager/index.tsx @@ -0,0 +1,43 @@ +import { useIsEnglishLocale } from '@automattic/i18n-utils'; +import { + MIGRATION_FLOW, + SITE_MIGRATION_FLOW, + HOSTED_SITE_MIGRATION_FLOW, + MIGRATION_SIGNUP_FLOW, +} from '@automattic/onboarding'; +import { Suspense } from 'react'; +import { useFlowNavigation } from '../../hooks/use-flow-navigation'; +import AsyncMigrationSurvey from '../../steps-repository/components/migration-survey/async'; + +const MIGRATION_SURVEY_FLOWS = [ + MIGRATION_FLOW, + SITE_MIGRATION_FLOW, + HOSTED_SITE_MIGRATION_FLOW, + MIGRATION_SIGNUP_FLOW, +]; + +const SurveyManager = () => { + const { params } = useFlowNavigation(); + const isEnLocale = useIsEnglishLocale(); + + // Skip survey for non-English locales + if ( ! isEnLocale ) { + return null; + } + + if ( ! params.flow ) { + return null; + } + + if ( ! MIGRATION_SURVEY_FLOWS.includes( params.flow ) ) { + return null; + } + + return ( + + + + ); +}; + +export default SurveyManager; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/async.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/async.tsx new file mode 100644 index 0000000000000..87cf9882c0e96 --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/async.tsx @@ -0,0 +1,5 @@ +import { lazy } from 'react'; + +const AsyncMigrationSurvey = lazy( () => import( './index' ) ); + +export default AsyncMigrationSurvey; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/index.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/index.tsx new file mode 100644 index 0000000000000..6881abef1203b --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/index.tsx @@ -0,0 +1,50 @@ +import { Button } from '@wordpress/components'; +import { translate } from 'i18n-calypso'; +import surveyImage from 'calypso/assets/images/onboarding/migrations/survey/wordpress-half-logo.png'; +import { Survey, SurveyProps, SurveyTriggerAccept, SurveyTriggerSkip } from '../survey'; +import './style.scss'; + +type MigrationSurveyProps = Pick< SurveyProps, 'isOpen' >; + +const MigrationSurvey = ( { isOpen }: MigrationSurveyProps ) => { + return ( + +
+ { +
+
+

+ { translate( 'Shape the Future of WordPress.com' ) } +

+
+ { translate( + 'Got a minute? Tell us about your WordPress.com journey in our brief survey and help us serve you better.' + ) } +
+
+ + + + + + +
+
+
+ ); +}; + +export default MigrationSurvey; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/style.scss b/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/style.scss new file mode 100644 index 0000000000000..a2021645d34e3 --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/migration-survey/style.scss @@ -0,0 +1,35 @@ +.migration-survey { + .migration-survey__popup-head { + background: #3858e9; + } + + .migration-survey__popup-content { + padding: 18px 24px 30px; + background: var(--studio-white); + + .migration-survey__popup-content-title { + font-weight: 500; + padding-bottom: 8px; + } + + .migration-survey__popup-content-description { + font-size: rem(14px); + line-height: 20px; + padding-bottom: 18px; + } + + .migration-survey__popup-content-buttons { + display: flex; + justify-content: flex-end; + } + .migration-survey__popup-img { + background: #0675c4; + padding-bottom: 57.9%; + + img { + width: 100%; + display: block; + } + } + } +} diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/index.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/index.tsx new file mode 100644 index 0000000000000..c4ce0991806a4 --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/index.tsx @@ -0,0 +1,170 @@ +import { Gridicon } from '@automattic/components'; +import { Button } from '@wordpress/components'; +import clsx from 'clsx'; +import cookie from 'cookie'; +import React, { cloneElement, useCallback, useContext, useMemo, useState } from 'react'; +import { recordTracksEvent } from 'calypso/lib/analytics/tracks'; +import { + type SurveyContextType, + type SurveyActionsContextType, + type TriggerProps, + type SurveyProps, +} from './types'; +export * from './types'; +import './style.scss'; + +const SurveyContext = React.createContext< SurveyContextType | undefined >( undefined ); + +export const SurveyActionsContext = React.createContext< SurveyActionsContextType >( { + accept: () => {}, + skip: () => {}, +} ); + +const Trigger = ( { asChild, children, onClick, as }: TriggerProps ) => { + if ( asChild ) { + return cloneElement( children, { onClick } ); + } + const Tag = as ?? 'button'; + return { children }; +}; + +export const SurveyTriggerAccept = ( { + children, + as = 'button', + asChild, +}: Omit< TriggerProps, 'onClick' > ) => { + const { accept } = useContext( SurveyActionsContext ); + return ( + + { children } + + ); +}; + +export const SurveyTriggerSkip = ( { + children, + as = 'span', + asChild, +}: Omit< TriggerProps, 'onClick' > ) => { + const { skip } = useContext( SurveyActionsContext ); + return ( + + { children } + + ); +}; + +const bemElement = + ( customClassName?: string ) => + ( element: string ): string | undefined => { + if ( customClassName ) { + return `${ customClassName }__${ element }`; + } + + return undefined; + }; + +const ONE_YEAR_IN_SECONDS = 1000 * 60 * 60 * 24 * 365; +const ONE_DAY_IN_SECONDS = 1000 * 60 * 60 * 24; +/** + * Generic Survey component + * @example + * ```tsx + * + *
+ *

Survey

+ * WordPress + * + * + * + * + * + * + *
+ *
+ * ``` + */ +export const Survey = ( { + children, + name, + onAccept, + onSkip, + isOpen = true, + title, + className, +}: SurveyProps ) => { + const cookieValue = cookie.parse( document.cookie ); + const shouldShow = ! cookieValue[ name ]; + const [ shouldShowSurvey, setShouldShowSurvey ] = useState( isOpen && shouldShow ); + const element = bemElement( className ); + + const handleClose = useCallback( + ( reason: 'skip' | 'accept' | 'skip_backdrop' ) => { + const PERIOD = reason === 'skip_backdrop' ? ONE_DAY_IN_SECONDS : ONE_YEAR_IN_SECONDS; + + document.cookie = cookie.serialize( name, reason, { + expires: new Date( Date.now() + PERIOD ), + } ); + + if ( reason === 'accept' ) { + recordTracksEvent( 'calypso_survey_accepted', { survey: name, action: reason } ); + onAccept?.(); + } + + if ( reason === 'skip' ) { + recordTracksEvent( 'calypso_survey_skipped', { survey: name, action: reason } ); + onSkip?.(); + } + + setShouldShowSurvey( false ); + }, + [ name, onAccept, onSkip ] + ); + + const actions = useMemo( + () => ( { + accept: () => handleClose( 'accept' ), + skip: () => handleClose( 'skip' ), + } ), + [ handleClose ] + ); + + if ( ! shouldShowSurvey ) { + return null; + } + + return ( + + +
+ + + +
+ { children } + + +
+
+ ); +}; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/style.scss b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/style.scss new file mode 100644 index 0000000000000..38e8db01a156c --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/style.scss @@ -0,0 +1,56 @@ +.survey-notice { + position: fixed; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: 1000; + + .survey-notice__backdrop { + background: var(--studio-black); + opacity: 0.2; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + cursor: default; + } + + .survey-notice__popup { + position: absolute; + right: 25px; + bottom: 25px; + width: 416px; + max-width: calc(100% - 50px); + z-index: 999; + border-radius: 2px; + box-shadow: + 0 3px 1px 0 rgba(0, 0, 0, 0.04), + 0 3px 8px 0 rgba(0, 0, 0, 0.12); + overflow: hidden; + background-color: var(--studio-white); + } + + .survey-notice__popup-head { + background: #0675c4; + border-bottom: 1px solid #f6f7f7; + height: 56px; + padding: 0 14px 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + + .survey-notice__popup-head-title { + color: var(--studio-white); + font-size: rem(14px); + font-weight: 500; + line-height: 20px; + letter-spacing: -0.15px; + } + + .survey-notice__popup-head-close svg { + fill: var(--studio-white); + } + } +} diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/test/index.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/test/index.tsx new file mode 100644 index 0000000000000..8d288c6d533f9 --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/test/index.tsx @@ -0,0 +1,160 @@ +/** + * @jest-environment jsdom + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Button } from '@wordpress/components'; +import cookie from 'cookie'; +import React from 'react'; +import { Survey, SurveyTriggerAccept, SurveyTriggerSkip } from '../'; + +const SURVEY_NAME = 'survey-test'; + +const removeSurveyCookie = () => { + document.cookie = cookie.serialize( SURVEY_NAME, 'skip', { + expires: new Date( 0 ), + } ); +}; + +describe( 'Survey', () => { + beforeEach( () => { + removeSurveyCookie(); + } ); + + it( 'renders the survey content', () => { + render( + +

Survey

+
+ ); + expect( screen.getByText( 'Survey' ) ).toBeInTheDocument(); + } ); + + it( 'closes the survey when ok button is clicked', async () => { + render( + +

Survey

+ + + + +
+ ); + await userEvent.click( screen.getByText( 'Take the survey' ) ); + + expect( screen.queryByText( 'Survey' ) ).not.toBeInTheDocument(); + } ); + + it( 'skips the survey when the skip trigger is clicked', async () => { + render( + +

Survey

+ + + + +
+ ); + await userEvent.click( screen.getByText( /Thanks/ ) ); + + expect( screen.queryByText( 'Survey' ) ).not.toBeInTheDocument(); + } ); + + it( 'triggers onAccept callback when user accepts', async () => { + const onAccept = jest.fn(); + render( + +

Survey

+ + + + +
+ ); + + await userEvent.click( screen.getByText( 'Take the survey' ) ); + + expect( onAccept ).toHaveBeenCalled(); + } ); + + it( 'triggers onSkip callback when user skips', async () => { + const onSkip = jest.fn(); + render( + +

Survey

+ + + + +
+ ); + + await userEvent.click( screen.getByText( 'Thanks' ) ); + + expect( onSkip ).toHaveBeenCalled(); + } ); + + it( "doesn't render the survey when it was already skipped", async () => { + document.cookie = cookie.serialize( 'survey-test', 'skip' ); + + render( + +

Survey

+ + + + +
+ ); + + expect( screen.queryByText( 'Survey' ) ).not.toBeInTheDocument(); + } ); + + it( 'does not render the survey when isOpen is false', () => { + render( + +

Survey

+
+ ); + + expect( screen.queryByText( 'Survey' ) ).not.toBeInTheDocument(); + } ); + + it( 'use trigger as child for accept', async () => { + const onAccept = jest.fn(); + + render( + +

Survey

+ + + Take the survey + +
+ ); + + await userEvent.click( screen.getByRole( 'link', { name: 'Take the survey' } ) ); + + expect( onAccept ).toHaveBeenCalled(); + expect( screen.queryByText( 'Survey' ) ).not.toBeInTheDocument(); + } ); + + it( 'use trigger as child for skip', async () => { + const onSkip = jest.fn(); + + render( + +

Survey

+ + + Thanks + +
+ ); + + await userEvent.click( screen.getByRole( 'link', { name: 'Thanks' } ) ); + + expect( onSkip ).toHaveBeenCalled(); + expect( screen.queryByText( 'Survey' ) ).not.toBeInTheDocument(); + } ); +} ); diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/types.ts b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/types.ts new file mode 100644 index 0000000000000..52814af7ef895 --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/components/survey/types.ts @@ -0,0 +1,25 @@ +import React, { PropsWithChildren } from 'react'; + +export interface SurveyContextType { + isOpen: boolean; +} +export interface SurveyActionsContextType { + accept: () => void; + skip: () => void; +} + +export interface SurveyProps extends PropsWithChildren { + name: string; + onAccept?: () => void; + onSkip?: () => void; + isOpen?: boolean; + className?: string; + title?: string; +} + +export interface TriggerProps { + asChild?: boolean; + onClick: () => void; + children: React.ReactElement; + as?: React.ElementType; +} diff --git a/client/landing/stepper/index.tsx b/client/landing/stepper/index.tsx index 55ce11a73ba2c..5d0dbeddd34a3 100644 --- a/client/landing/stepper/index.tsx +++ b/client/landing/stepper/index.tsx @@ -38,6 +38,7 @@ import { FlowRenderer } from './declarative-flow/internals'; import { AsyncHelpCenter } from './declarative-flow/internals/components'; import 'calypso/components/environment-badge/style.scss'; import 'calypso/assets/stylesheets/style.scss'; +import SurveyManager from './declarative-flow/internals/components/survery-manager'; import availableFlows from './declarative-flow/registered-flows'; import { USER_STORE } from './stores'; import { setupWpDataDebug } from './utils/devtools'; @@ -172,8 +173,10 @@ window.AppBoot = async () => { placeholder={ null } id="notices" /> + + { 'development' === process.env.NODE_ENV && ( ) }