-
Notifications
You must be signed in to change notification settings - Fork 2k
/
index.tsx
275 lines (242 loc) · 9.25 KB
/
index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
import {
SENSEI_FLOW,
isAnyHostingFlow,
isNewsletterOrLinkInBioFlow,
isSenseiFlow,
isWooExpressFlow,
} from '@automattic/onboarding';
import { useSelect, useDispatch } from '@wordpress/data';
import { useI18n } from '@wordpress/react-i18n';
import React, { useEffect, useCallback, useMemo, Suspense, lazy } from 'react';
import Modal from 'react-modal';
import { Navigate, Route, Routes, generatePath, useNavigate, useLocation } from 'react-router-dom';
import DocumentHead from 'calypso/components/data/document-head';
import { STEPPER_INTERNAL_STORE } from 'calypso/landing/stepper/stores';
import { recordPageView } from 'calypso/lib/analytics/page-view';
import { recordSignupStart } from 'calypso/lib/analytics/signup';
import AsyncCheckoutModal from 'calypso/my-sites/checkout/modal/async';
import {
getSignupCompleteFlowNameAndClear,
getSignupCompleteStepNameAndClear,
} from 'calypso/signup/storageUtils';
import { useSelector } from 'calypso/state';
import { getSite, isRequestingSite } from 'calypso/state/sites/selectors';
import { useQuery } from '../../hooks/use-query';
import { useSaveQueryParams } from '../../hooks/use-save-query-params';
import { useSiteData } from '../../hooks/use-site-data';
import useSyncRoute from '../../hooks/use-sync-route';
import { ONBOARD_STORE } from '../../stores';
import kebabCase from '../../utils/kebabCase';
import { getAssemblerSource } from './analytics/record-design';
import recordStepStart from './analytics/record-step-start';
import { StepRoute, StepperLoader } from './components';
import { AssertConditionState, Flow, StepperStep, StepProps } from './types';
import './global.scss';
import type { OnboardSelect, StepperInternalSelect } from '@automattic/data-stores';
/**
* This can be used when renaming a step. Simply add a map entry with the new step slug and the old step slug and Stepper will fire `calypso_signup_step_start` events for both slugs. This ensures that funnels with the old slug will still work.
*/
export const getStepOldSlug = ( stepSlug: string ): string | undefined => {
const stepSlugMap: Record< string, string > = {
'create-site': 'site-creation-step',
};
return stepSlugMap[ stepSlug ];
};
/**
* This component accepts a single flow property. It does the following:
*
* 1. It renders a react-router route for every step in the flow.
* 2. It gives every step the ability to navigate back and forth within the flow
* 3. It's responsive to the dynamic changes in side the flow's hooks (useSteps and useStepsNavigation)
* @param props
* @param props.flow the flow you want to render
* @returns A React router switch will all the routes
*/
export const FlowRenderer: React.FC< { flow: Flow } > = ( { flow } ) => {
// Configure app element that React Modal will aria-hide when modal is open
Modal.setAppElement( '#wpcom' );
const flowSteps = flow.useSteps();
const stepPaths = flowSteps.map( ( step ) => step.slug );
const stepComponents: Record< string, React.FC< StepProps > > = useMemo(
() =>
flowSteps.reduce(
( acc, flowStep ) => ( {
...acc,
[ flowStep.slug ]:
'asyncComponent' in flowStep ? lazy( flowStep.asyncComponent ) : flowStep.component,
} ),
{}
),
[ flowSteps ]
);
const location = useLocation();
const currentStepRoute = location.pathname.split( '/' )[ 2 ]?.replace( /\/+$/, '' );
const stepOldSlug = getStepOldSlug( currentStepRoute );
const { __ } = useI18n();
const navigate = useNavigate();
const { setStepData } = useDispatch( STEPPER_INTERNAL_STORE );
const intent = useSelect(
( select ) => ( select( ONBOARD_STORE ) as OnboardSelect ).getIntent(),
[]
);
const design = useSelect(
( select ) => ( select( ONBOARD_STORE ) as OnboardSelect ).getSelectedDesign(),
[]
);
useSaveQueryParams();
const ref = useQuery().get( 'ref' ) || '';
const { site, siteSlugOrId } = useSiteData();
// Ensure that the selected site is fetched, if available. This is used for event tracking purposes.
// See https://github.com/Automattic/wp-calypso/pull/82981.
const selectedSite = useSelector( ( state ) => site && getSite( state, siteSlugOrId ) );
const isRequestingSelectedSite = useSelector(
( state ) => site && isRequestingSite( state, siteSlugOrId )
);
// Short-circuit this if the site slug or ID is not available.
const hasRequestedSelectedSite = siteSlugOrId
? !! selectedSite && ! isRequestingSelectedSite
: true;
// this pre-loads all the lazy steps down the flow.
useEffect( () => {
Promise.all( flowSteps.map( ( step ) => 'asyncComponent' in step && step.asyncComponent() ) );
}, stepPaths );
const isFlowStart = useCallback( () => {
if ( ! flow || ! stepPaths.length ) {
return false;
}
if ( flow.name === SENSEI_FLOW ) {
return currentStepRoute === stepPaths[ 1 ];
}
return currentStepRoute === stepPaths[ 0 ];
}, [ flow, currentStepRoute, ...stepPaths ] );
const _navigate = async ( path: string, extraData = {} ) => {
// If any extra data is passed to the navigate() function, store it to the stepper-internal store.
setStepData( {
path: path,
intent: intent,
previousStep: currentStepRoute,
...extraData,
} );
const _path = path.includes( '?' ) // does path contain search params
? generatePath( `/${ flow.variantSlug ?? flow.name }/${ path }` )
: generatePath( `/${ flow.variantSlug ?? flow.name }/${ path }${ window.location.search }` );
navigate( _path, { state: stepPaths } );
};
const stepNavigation = flow.useStepNavigation(
currentStepRoute,
_navigate,
flowSteps.map( ( step ) => step.slug )
);
// Retrieve any extra step data from the stepper-internal store. This will be passed as a prop to the current step.
const stepData = useSelect(
( select ) => ( select( STEPPER_INTERNAL_STORE ) as StepperInternalSelect ).getStepData(),
[]
);
flow.useSideEffect?.( currentStepRoute, _navigate );
useSyncRoute();
useEffect( () => {
window.scrollTo( 0, 0 );
}, [ location ] );
// Get any flow-specific event props to include in the
// `calypso_signup_start` Tracks event triggerd in the effect below.
const signupStartEventProps = flow.useSignupStartEventProps?.() ?? {};
useEffect( () => {
if ( flow.isSignupFlow && isFlowStart() ) {
recordSignupStart( flow.name, ref, signupStartEventProps );
}
}, [ flow, ref, isFlowStart ] );
useEffect( () => {
// We record the event only when the step is not empty. Additionally, we should not fire this event whenever the intent is changed
if ( ! currentStepRoute || ! hasRequestedSelectedSite ) {
return;
}
const signupCompleteFlowName = getSignupCompleteFlowNameAndClear();
const signupCompleteStepName = getSignupCompleteStepNameAndClear();
const isReEnteringStep =
signupCompleteFlowName === flow.name && signupCompleteStepName === currentStepRoute;
if ( ! isReEnteringStep ) {
recordStepStart( flow.name, kebabCase( currentStepRoute ), {
intent,
is_in_hosting_flow: isAnyHostingFlow( flow.name ),
...( design && { assembler_source: getAssemblerSource( design ) } ),
} );
if ( stepOldSlug ) {
recordStepStart( flow.name, kebabCase( stepOldSlug ), {
intent,
is_in_hosting_flow: isAnyHostingFlow( flow.name ),
...( design && { assembler_source: getAssemblerSource( design ) } ),
} );
}
}
// Also record page view for data and analytics
const pathname = window.location.pathname || '';
const pageTitle = `Setup > ${ flow.name } > ${ currentStepRoute }`;
recordPageView( pathname, pageTitle );
// We leave out intent from the dependency list, due to the ONBOARD_STORE being reset in the exit flow.
// This causes the intent to become empty, and thus this event being fired again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ flow.name, currentStepRoute, hasRequestedSelectedSite ] );
const assertCondition = flow.useAssertConditions?.( _navigate ) ?? {
state: AssertConditionState.SUCCESS,
};
const renderStep = ( step: StepperStep ) => {
switch ( assertCondition.state ) {
case AssertConditionState.CHECKING:
/* eslint-disable wpcalypso/jsx-classname-namespace */
return <StepperLoader />;
/* eslint-enable wpcalypso/jsx-classname-namespace */
case AssertConditionState.FAILURE:
return null;
}
const StepComponent = stepComponents[ step.slug ];
return (
<StepComponent
navigation={ stepNavigation }
flow={ flow.name }
variantSlug={ flow.variantSlug }
stepName={ step.slug }
data={ stepData }
/>
);
};
const getDocumentHeadTitle = () => {
if ( isNewsletterOrLinkInBioFlow( flow.name ) ) {
return flow.title;
} else if ( isSenseiFlow( flow.name ) ) {
return __( 'Course Creator' );
}
};
return (
<Suspense fallback={ <StepperLoader /> }>
<DocumentHead title={ getDocumentHeadTitle() } />
<Routes>
{ flowSteps.map( ( step ) => (
<Route
key={ step.slug }
path={ `/${ flow.variantSlug ?? flow.name }/${ step.slug }` }
element={
<StepRoute
step={ step }
flow={ flow }
showWooLogo={ isWooExpressFlow( flow.name ) }
renderStep={ renderStep }
/>
}
/>
) ) }
<Route
path="*"
element={
<Navigate
to={ `/${ flow.variantSlug ?? flow.name }/${ stepPaths[ 0 ] }${
window.location.search
}` }
replace
/>
}
/>
</Routes>
<AsyncCheckoutModal siteId={ site?.ID } />
</Suspense>
);
};