add: core profiler business info page (#38412)

* add: core profiler business info page
This commit is contained in:
RJ 2023-05-30 17:05:38 +10:00 committed by GitHub
parent 953a8f5c30
commit ab18828e84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1278 additions and 131 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add ability to make geolocation call to WPCOM API

View File

@ -3,6 +3,8 @@ export enum TYPES {
GET_LOCALES_SUCCESS = 'GET_LOCALES_SUCCESS',
GET_COUNTRIES_ERROR = 'GET_COUNTRIES_ERROR',
GET_COUNTRIES_SUCCESS = 'GET_COUNTRIES_SUCCESS',
GEOLOCATION_SUCCESS = 'GEOLOCATION_SUCCESS',
GEOLOCATION_ERROR = 'GEOLOCATION_ERROR',
}
export default TYPES;

View File

@ -2,7 +2,7 @@
* Internal dependencies
*/
import TYPES from './action-types';
import { Locales, Country } from './types';
import { Locales, Country, GeolocationResponse } from './types';
export function getLocalesSuccess( locales: Locales ) {
return {
@ -32,9 +32,25 @@ export function getCountriesError( error: unknown ) {
};
}
export function geolocationSuccess( geolocation: GeolocationResponse ) {
return {
type: TYPES.GEOLOCATION_SUCCESS as const,
geolocation,
};
}
export function geolocationError( error: unknown ) {
return {
type: TYPES.GEOLOCATION_ERROR as const,
error,
};
}
export type Action = ReturnType<
| typeof getLocalesSuccess
| typeof getLocalesError
| typeof getCountriesSuccess
| typeof getCountriesError
| typeof geolocationSuccess
| typeof geolocationError
>;

View File

@ -16,6 +16,7 @@ const reducer: Reducer< CountriesState, Action > = (
errors: {},
locales: {},
countries: [],
geolocation: undefined,
},
action
) => {
@ -50,6 +51,21 @@ const reducer: Reducer< CountriesState, Action > = (
},
};
break;
case TYPES.GEOLOCATION_SUCCESS:
state = {
...state,
geolocation: action.geolocation,
};
break;
case TYPES.GEOLOCATION_ERROR:
state = {
...state,
errors: {
...state.errors,
geolocation: action.error,
},
};
break;
}
return state;
};

View File

@ -3,10 +3,12 @@
*/
import { apiFetch, select } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
import { DispatchFromMap } from '@automattic/data-stores';
/**
* Internal dependencies
*/
import * as actions from './actions';
import {
getLocalesSuccess,
getLocalesError,
@ -14,7 +16,7 @@ import {
getCountriesError,
} from './actions';
import { NAMESPACE } from '../constants';
import { Locales, Country } from './types';
import { Locales, Country, GeolocationResponse } from './types';
import { STORE_NAME } from './constants';
const resolveSelect =
@ -55,3 +57,18 @@ export function* getCountries() {
return getCountriesError( error );
}
}
export const geolocate =
() =>
async ( { dispatch }: { dispatch: DispatchFromMap< typeof actions > } ) => {
try {
const url = `https://public-api.wordpress.com/geo/?v=${ new Date().getTime() }`;
const response = await fetch( url, {
method: 'GET',
} );
const result: GeolocationResponse = await response.json();
dispatch.geolocationSuccess( result );
} catch ( error ) {
dispatch.geolocationError( error );
}
};

View File

@ -19,3 +19,7 @@ export const getCountries = ( state: CountriesState ) => {
export const getCountry = ( state: CountriesState, code: string ) => {
return state.countries.find( ( country ) => country.code === code );
};
export const geolocate = ( state: CountriesState ) => {
return state.geolocation;
};

View File

@ -40,4 +40,26 @@ export type CountriesState = {
};
locales: Locales;
countries: Country[];
geolocation: GeolocationResponse | undefined;
};
/**
* Geolocation response from the WPCOM API which geolocates using ip2location.
* Example response:
* {
* "latitude":"-38.23476",
* "longitude":"146.39499",
* "country_short":"AU",
* "country_long":"Australia",
* "region":"Victoria",
* "city":"Morwell"
* }
*/
export type GeolocationResponse = {
latitude: string;
longitude: string;
country_short: string;
country_long: string;
region: string;
city: string;
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Added getCountry utility for splitting colon delimited country:state strings

View File

@ -77,3 +77,12 @@ export const findCountryOption = (
}
return match;
};
/**
* Returns just the country portion of a country:state string that is delimited with a colon.
*
* @param countryPossiblyWithState Country string that may or may not have a state. e.g 'US:CA', 'UG:UG312'
* @return Just the country portion of the string, or undefined if input is undefined. e.g 'US', 'UG'
*/
export const getCountry = ( countryPossiblyWithState: string ) =>
countryPossiblyWithState?.split( ':' )[ 0 ] ?? undefined;

View File

@ -4,7 +4,7 @@
/**
* Internal dependencies
*/
import { findCountryOption } from '../../';
import { findCountryOption, getCountry } from '../../';
import { countryStateOptions } from './country-options';
import { locations } from './locations';
@ -94,3 +94,18 @@ describe( 'findCountryOption', () => {
expect( matchCount / locations.length ).toBeGreaterThan( 0.98 );
} );
} );
describe( 'getCountry', () => {
it( 'should return null on undefined location', () => {
// @ts-expect-error -- tests undefined
expect( getCountry( undefined ) ).toEqual( undefined );
} );
it( 'should return country when country string without state is passed', () => {
expect( getCountry( 'SG' ) ).toEqual( 'SG' );
} );
it( 'should return country when country string with state is passed', () => {
expect( getCountry( 'AU:VIC' ) ).toEqual( 'AU' );
} );
} );

View File

@ -47,7 +47,7 @@
}
@media (max-width: #{ ($break-mobile) }) {
margin-top: 52px 0 40px;
margin: 52px 0 40px;
.woocommerce-profiler-heading__title {
font-size: 32px;

View File

@ -13,10 +13,12 @@ import {
Country,
ONBOARDING_STORE_NAME,
Extension,
GeolocationResponse,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { getSetting } from '@woocommerce/settings';
import { initializeExPlat } from '@woocommerce/explat';
import { CountryStateOption } from '@woocommerce/onboarding';
/**
* Internal dependencies
@ -28,7 +30,11 @@ import {
SellingOnlineAnswer,
SellingPlatform,
} from './pages/UserProfile';
import { BusinessInfo } from './pages/BusinessInfo';
import {
BusinessInfo,
IndustryChoice,
POSSIBLY_DEFAULT_STORE_NAMES,
} from './pages/BusinessInfo';
import { BusinessLocation } from './pages/BusinessLocation';
import { getCountryStateOptions } from './services/country';
import { Loader } from './pages/Loader';
@ -42,9 +48,6 @@ import {
PluginInstallError,
} from './services/installAndActivatePlugins';
// TODO: Typescript support can be improved, but for now lets write the types ourselves
// https://stately.ai/blog/introducing-typescript-typegen-for-xstate
export type InitializationCompleteEvent = {
type: 'INITIALIZATION_COMPLETE';
payload: { optInDataSharing: boolean };
@ -69,14 +72,17 @@ export type UserProfileEvent =
export type BusinessInfoEvent = {
type: 'BUSINESS_INFO_COMPLETED';
payload: {
businessInfo: CoreProfilerStateMachineContext[ 'businessInfo' ];
storeName?: string;
industry?: IndustryChoice;
storeLocation: CountryStateOption[ 'key' ];
geolocationOverruled: boolean;
};
};
export type BusinessLocationEvent = {
type: 'BUSINESS_LOCATION_COMPLETED';
payload: {
businessInfo: CoreProfilerStateMachineContext[ 'businessInfo' ];
storeLocation: CountryStateOption[ 'key' ];
};
};
@ -90,9 +96,11 @@ export type PluginsInstallationRequestedEvent = {
// TODO: add types as we develop the pages
export type OnboardingProfile = {
business_choice: BusinessChoice;
industry: Array< IndustryChoice >;
selling_online_answer: SellingOnlineAnswer | null;
selling_platforms: SellingPlatform[] | null;
skip?: boolean;
is_store_country_set: boolean | null;
};
export type PluginsPageSkippedEvent = {
@ -128,20 +136,23 @@ export type CoreProfilerStateMachineContext = {
sellingPlatforms?: SellingPlatform[] | null;
skipped?: boolean;
};
geolocatedLocation: {
location: string;
};
pluginsAvailable: ExtensionList[ 'plugins' ] | [];
pluginsSelected: string[]; // extension slugs
pluginsInstallationErrors: PluginInstallError[];
businessInfo: { foo?: { bar: 'qux' }; location: string };
countries: { [ key: string ]: string };
geolocatedLocation: GeolocationResponse | undefined;
businessInfo: {
storeName?: string | undefined;
industry?: string | undefined;
location?: string;
};
countries: CountryStateOption[];
loader: {
progress?: number;
className?: string;
useStages?: string;
stageIndex?: number;
};
onboardingProfile: OnboardingProfile;
};
const getAllowTrackingOption = async () =>
@ -156,6 +167,40 @@ const handleTrackingOption = assign( {
) => event.data !== 'no',
} );
const getStoreNameOption = async () =>
resolveSelect( OPTIONS_STORE_NAME ).getOption( 'blogname' );
const handleStoreNameOption = assign( {
businessInfo: (
context: CoreProfilerStateMachineContext,
event: DoneInvokeEvent< string | undefined >
) => {
return {
...context.businessInfo,
storeName: POSSIBLY_DEFAULT_STORE_NAMES.includes( event.data ) // if its empty or the default, show empty to the user
? undefined
: event.data,
};
},
} );
const getStoreCountryOption = async () =>
resolveSelect( OPTIONS_STORE_NAME ).getOption(
'woocommerce_default_country'
);
const handleStoreCountryOption = assign( {
businessInfo: (
context: CoreProfilerStateMachineContext,
event: DoneInvokeEvent< string | undefined >
) => {
return {
...context.businessInfo,
location: event.data,
};
},
} );
/**
* Prefetch it so that @wp/data caches it and there won't be a loading delay when its used
*/
@ -167,6 +212,20 @@ const preFetchGetCountries = assign( {
),
} );
const preFetchOptions = assign( {
spawnPrefetchOptionsRef: ( _ctx, _evt, { action } ) => {
spawn(
Promise.all( [
// @ts-expect-error -- not sure its possible to type this yet, maybe in xstate v5
action.options.map( ( optionName: string ) =>
resolveSelect( OPTIONS_STORE_NAME ).getOption( optionName )
),
] ),
'core-profiler-prefetch-options'
);
},
} );
const getCountries = async () =>
resolveSelect( COUNTRIES_STORE_NAME ).getCountries();
@ -196,7 +255,6 @@ const handleOnboardingProfileOption = assign( {
selling_platforms: sellingPlatforms,
...rest
} = event.data;
return {
...rest,
businessChoice,
@ -206,6 +264,37 @@ const handleOnboardingProfileOption = assign( {
},
} );
const assignOnboardingProfile = assign( {
onboardingProfile: (
_context,
event: DoneInvokeEvent< OnboardingProfile | undefined >
) => event.data,
} );
const getGeolocation = async ( context: CoreProfilerStateMachineContext ) => {
if ( context.optInDataSharing ) {
return resolveSelect( COUNTRIES_STORE_NAME ).geolocate();
}
return undefined;
};
const preFetchGeolocation = assign( {
spawnGeolocationRef: ( context: CoreProfilerStateMachineContext ) =>
spawn(
getGeolocation( context ),
'core-profiler-prefetch-geolocation'
),
} );
const handleGeolocation = assign( {
geolocatedLocation: (
_context,
event: DoneInvokeEvent< GeolocationResponse >
) => {
return event.data;
},
} );
const redirectToWooHome = () => {
/**
* @todo replace with navigateTo
@ -271,6 +360,35 @@ const recordTracksPluginsSkipped = () => {
recordEvent( 'storeprofiler_plugins_skip' );
};
const recordTracksBusinessInfoViewed = () => {
recordEvent( 'storeprofiler_step_view', {
step: 'business_info',
wc_version: getSetting( 'wcVersion' ),
} );
};
const recordTracksBusinessInfoCompleted = (
_context: CoreProfilerStateMachineContext,
event: Extract< BusinessInfoEvent, { type: 'BUSINESS_INFO_COMPLETED' } >
) => {
recordEvent( 'storeprofiler_step_complete', {
step: 'business_info',
wc_version: getSetting( 'wcVersion' ),
} );
recordEvent( 'storeprofiler_business_info', {
business_name_filled:
POSSIBLY_DEFAULT_STORE_NAMES.findIndex(
( name ) => name === event.payload.storeName
) === -1,
industry: event.payload.industry,
store_location_previously_set:
_context.onboardingProfile.is_store_country_set || false,
geolocation_success: _context.geolocatedLocation !== undefined,
geolocation_overruled: event.payload.geolocationOverruled,
} );
};
const recordTracksSkipBusinessLocationViewed = () => {
recordEvent( 'storeprofiler_step_view', {
step: 'skip_business_location',
@ -280,7 +398,7 @@ const recordTracksSkipBusinessLocationViewed = () => {
const recordTracksSkipBusinessLocationCompleted = () => {
recordEvent( 'storeprofiler_step_complete', {
step: 'skp_business_location',
step: 'skip_business_location',
wc_version: getSetting( 'wcVersion' ),
} );
};
@ -306,15 +424,16 @@ const updateTrackingOption = (
} );
};
// TODO: move the data references over to the context.onboardingProfile object which stores the entire woocommerce_onboarding_profile contents
const updateOnboardingProfileOption = (
context: CoreProfilerStateMachineContext
) => {
const { businessChoice, sellingOnlineAnswer, sellingPlatforms, ...rest } =
const { businessChoice, sellingOnlineAnswer, sellingPlatforms } =
context.userProfile;
return dispatch( OPTIONS_STORE_NAME ).updateOptions( {
woocommerce_onboarding_profile: {
...rest,
...context.onboardingProfile,
business_choice: businessChoice,
selling_online_answer: sellingOnlineAnswer,
selling_platforms: sellingPlatforms,
@ -328,6 +447,35 @@ const updateBusinessLocation = ( countryAndState: string ) => {
} );
};
const updateBusinessInfo = async (
_ctx: CoreProfilerStateMachineContext,
event: BusinessInfoEvent
) => {
const refreshedOnboardingProfile = ( await resolveSelect(
OPTIONS_STORE_NAME
).getOption( 'woocommerce_onboarding_profile' ) ) as OnboardingProfile;
return dispatch( OPTIONS_STORE_NAME ).updateOptions( {
blogname: event.payload.storeName,
woocommerce_default_country: event.payload.storeLocation,
woocommerce_onboarding_profile: {
...refreshedOnboardingProfile,
is_store_country_set: true,
industry: [ event.payload.industry ],
},
} );
};
const persistBusinessInfo = assign( {
persistBusinessInfoRef: (
_ctx: CoreProfilerStateMachineContext,
event: BusinessInfoEvent
) =>
spawn(
updateBusinessInfo( _ctx, event ),
'core-profiler-update-business-info'
),
} );
const promiseDelay = ( milliseconds: number ) => {
return new Promise( ( resolve ) => {
setTimeout( resolve, milliseconds );
@ -372,12 +520,14 @@ const handlePlugins = assign( {
event.data,
} );
const coreProfilerMachineActions = {
updateTrackingOption,
export const preFetchActions = {
preFetchGetPlugins,
preFetchGetCountries,
handleTrackingOption,
handlePlugins,
preFetchGeolocation,
preFetchOptions,
};
export const recordTracksActions = {
recordTracksIntroCompleted,
recordTracksIntroSkipped,
recordTracksIntroViewed,
@ -388,15 +538,33 @@ const coreProfilerMachineActions = {
recordTracksPluginsSkipped,
recordTracksSkipBusinessLocationViewed,
recordTracksSkipBusinessLocationCompleted,
recordTracksBusinessInfoViewed,
recordTracksBusinessInfoCompleted,
};
const coreProfilerMachineActions = {
...preFetchActions,
...recordTracksActions,
handlePlugins,
updateTrackingOption,
handleTrackingOption,
handleGeolocation,
handleStoreNameOption,
handleStoreCountryOption,
assignOptInDataSharing,
handleCountries,
handleOnboardingProfileOption,
assignOnboardingProfile,
persistBusinessInfo,
redirectToWooHome,
};
const coreProfilerMachineServices = {
getAllowTrackingOption,
getStoreNameOption,
getStoreCountryOption,
getCountries,
getGeolocation,
getOnboardingProfileOption,
getPlugins,
};
@ -409,37 +577,66 @@ export const coreProfilerStateMachineDefinition = createMachine( {
// actual defaults displayed to the user should be handled in the steps themselves
optInDataSharing: false,
userProfile: { skipped: true },
geolocatedLocation: { location: 'US:CA' },
businessInfo: { location: 'US:CA' },
geolocatedLocation: undefined,
businessInfo: {
storeName: undefined,
industry: undefined,
storeCountryPreviouslySet: false,
location: 'US:CA',
},
countries: [] as CountryStateOption[],
pluginsAvailable: [],
pluginsSelected: [],
pluginsInstallationErrors: [],
countries: {},
pluginsSelected: [],
loader: {},
onboardingProfile: {} as OnboardingProfile,
} as CoreProfilerStateMachineContext,
states: {
initializing: {
on: {
INITIALIZATION_COMPLETE: {
target: 'introOptIn',
},
},
entry: [ 'preFetchGetPlugins', 'preFetchGetCountries' ],
invoke: [
entry: [
// these prefetch tasks are spawned actors in the background and do not block progression of the state machine
'preFetchGetPlugins',
'preFetchGetCountries',
{
src: 'getAllowTrackingOption',
// eslint-disable-next-line xstate/no-ondone-outside-compound-state -- The invoke.onDone property refers to the invocation (invoke.src) being done, not the onDone property on a state node.
onDone: [
{
actions: [ 'handleTrackingOption' ],
target: 'introOptIn',
},
type: 'preFetchOptions',
options: [
'blogname',
'woocommerce_onboarding_profile',
'woocommerce_default_country',
],
onError: {
target: 'introOptIn', // leave it as initialised default on error
},
},
],
type: 'parallel',
states: {
// if we have any other init tasks to do in parallel, add them as a parallel state here.
// this blocks the introOptIn UI from loading keep that in mind when adding new tasks here
trackingOption: {
initial: 'fetching',
states: {
fetching: {
invoke: {
src: 'getAllowTrackingOption',
onDone: [
{
actions: [ 'handleTrackingOption' ],
target: 'done',
},
],
onError: {
target: 'done', // leave it as initialised default on error
},
},
},
done: {
type: 'final',
},
},
},
},
onDone: {
target: 'introOptIn',
// TODO: at this point, we can handle the URL path param if any and jump to the correct page
},
meta: {
progress: 0,
},
@ -484,7 +681,10 @@ export const coreProfilerStateMachineDefinition = createMachine( {
src: 'getOnboardingProfileOption',
onDone: [
{
actions: [ 'handleOnboardingProfileOption' ],
actions: [
'handleOnboardingProfileOption',
'assignOnboardingProfile',
],
target: 'userProfile',
},
],
@ -494,7 +694,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
},
},
userProfile: {
entry: [ 'recordTracksUserProfileViewed' ],
entry: [ 'recordTracksUserProfileViewed', 'preFetchGeolocation' ],
on: {
USER_PROFILE_COMPLETED: {
target: 'postUserProfile',
@ -546,28 +746,117 @@ export const coreProfilerStateMachineDefinition = createMachine( {
},
},
preBusinessInfo: {
always: [
// immediately transition to businessInfo without any events as long as geolocation parallel has completed
{
target: 'businessInfo',
cond: () => true, // TODO: use a custom function to check on the parallel state using meta when we implement that. https://xstate.js.org/docs/guides/guards.html#guards-condition-functions
type: 'parallel',
states: {
geolocation: {
initial: 'checkDataOptIn',
states: {
checkDataOptIn: {
// if the user has opted out of data sharing, we skip the geolocation step
always: [
{
cond: ( context ) =>
context.optInDataSharing,
target: 'fetching',
},
{
target: 'done',
},
],
},
fetching: {
invoke: {
src: 'getGeolocation',
onDone: {
target: 'done',
actions: 'handleGeolocation',
},
// onError TODO: handle error
},
},
done: {
type: 'final',
},
},
},
],
storeCountryOption: {
initial: 'fetching',
states: {
fetching: {
invoke: {
src: 'getStoreCountryOption',
onDone: [
{
actions: [ 'handleStoreCountryOption' ],
target: 'done',
},
],
onError: {
target: 'done',
},
},
},
done: {
type: 'final',
},
},
},
storeNameOption: {
initial: 'fetching',
states: {
fetching: {
invoke: {
src: 'getStoreNameOption',
onDone: [
{
actions: [ 'handleStoreNameOption' ],
target: 'done',
},
],
onError: {
target: 'done', // leave it as initialised default on error
},
},
},
done: {
type: 'final',
},
},
},
countries: {
initial: 'fetching',
states: {
fetching: {
invoke: {
src: 'getCountries',
onDone: {
target: 'done',
actions: 'handleCountries',
},
},
},
done: {
type: 'final',
},
},
},
},
// onDone is reached when child parallel states are all at their final states
onDone: {
target: 'businessInfo',
},
meta: {
progress: 50,
},
},
businessInfo: {
entry: [ 'recordTracksBusinessInfoViewed' ],
on: {
BUSINESS_INFO_COMPLETED: {
target: 'prePlugins',
actions: [
assign( {
businessInfo: (
_context,
event: BusinessInfoEvent
) => event.payload.businessInfo, // assign context.businessInfo to the payload of the event
} ),
'persistBusinessInfo',
'recordTracksBusinessInfoCompleted',
],
},
},
@ -599,7 +888,12 @@ export const coreProfilerStateMachineDefinition = createMachine( {
businessInfo: (
_context,
event: BusinessLocationEvent
) => event.payload.businessInfo, // assign context.businessInfo to the payload of the event
) => {
return {
..._context.businessInfo,
location: event.payload.storeLocation,
};
},
} ),
'recordTracksSkipBusinessLocationCompleted',
],
@ -623,7 +917,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
invoke: {
src: ( context ) => {
return updateBusinessLocation(
context.businessInfo.location
context.businessInfo.location as string
);
},
onDone: {
@ -899,7 +1193,9 @@ export const CoreProfilerController = ( {
} );
}, [ actionOverrides, servicesOverrides ] );
const [ state, send ] = useMachine( augmentedStateMachine );
const [ state, send ] = useMachine( augmentedStateMachine, {
devTools: process.env.NODE_ENV === 'development',
} );
const stateValue =
typeof state.value === 'object'
? Object.keys( state.value )[ 0 ]

View File

@ -1,34 +1,353 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, TextControl, Notice } from '@wordpress/components';
import { SelectControl } from '@woocommerce/components';
import { Icon, chevronDown } from '@wordpress/icons';
import {
createInterpolateElement,
useEffect,
useState,
} from '@wordpress/element';
import { findCountryOption, getCountry } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import { CoreProfilerStateMachineContext, BusinessInfoEvent } from '../index';
import { CountryStateOption } from '../services/country';
import { Heading } from '../components/heading/heading';
import { Navigation } from '../components/navigation/navigation';
/** These are some store names that are known to be set by default and not likely to be used as actual names */
export const POSSIBLY_DEFAULT_STORE_NAMES = [
undefined,
'woocommerce',
'Site Title',
'',
];
export type IndustryChoice = typeof industryChoices[ number ][ 'key' ];
export const industryChoices = [
{
label: __( 'Clothing and accessories', 'woocommerce' ),
key: 'clothing_and_accessories' as const,
},
{
label: __( 'Health and beauty', 'woocommerce' ),
key: 'health_and_beauty' as const,
},
{
label: __( 'Food and drink', 'woocommerce' ),
key: 'food_and_drink' as const,
},
{
label: __( 'Home, furniture and garden', 'woocommerce' ),
key: 'home_furniture_and_garden' as const,
},
{
label: __( 'Education and learning', 'woocommerce' ),
key: 'education_and_learning' as const,
},
{
label: __( 'Electronics and computers', 'woocommerce' ),
key: 'electronics_and_computers' as const,
},
{
label: __( 'Other', 'woocommerce' ),
key: 'other' as const,
},
];
export type IndustryChoiceOption = typeof industryChoices[ number ];
export const selectIndustryMapping = {
im_just_starting_my_business: __(
'What type of products or services do you plan to sell?',
'woocommerce'
),
im_already_selling: __(
'Which industry is your business in?',
'woocommerce'
),
im_setting_up_a_store_for_a_client: __(
"Which industry is your client's business in?",
'woocommerce'
),
};
export const BusinessInfo = ( {
context,
navigationProgress,
sendEvent,
}: {
context: CoreProfilerStateMachineContext;
navigationProgress: number;
sendEvent: ( event: BusinessInfoEvent ) => void;
} ) => {
return (
<>
<div>Business Info</div>
<div>{ context.geolocatedLocation.location }</div>
<button
onClick={ () =>
sendEvent( {
type: 'BUSINESS_INFO_COMPLETED',
payload: {
businessInfo: {
foo: { bar: 'qux' },
location: 'US:CA',
},
},
} )
const {
geolocatedLocation,
userProfile: { businessChoice },
businessInfo,
countries,
onboardingProfile: {
is_store_country_set: isStoreCountrySet,
industry: industryFromOnboardingProfile,
},
} = context;
const [ storeName, setStoreName ] = useState(
businessInfo.storeName || ''
);
const [ storeCountry, setStoreCountry ] = useState< CountryStateOption >( {
key: '',
label: '',
} );
useEffect( () => {
if ( isStoreCountrySet ) {
const previouslyStoredCountryOption = countries.find(
( country ) => country.key === businessInfo.location
);
setStoreCountry(
previouslyStoredCountryOption || { key: '', label: '' }
);
}
}, [ businessInfo.location, countries, isStoreCountrySet ] );
const [ geolocationMatch, setGeolocationMatch ] = useState( {
key: '',
label: '',
} );
useEffect( () => {
if ( geolocatedLocation ) {
const foundCountryOption = findCountryOption(
countries,
geolocatedLocation
);
if ( foundCountryOption ) {
setGeolocationMatch( foundCountryOption );
if ( ! isStoreCountrySet ) {
setStoreCountry( foundCountryOption );
}
>
Next
</button>
</>
}
}
}, [ countries, isStoreCountrySet, geolocatedLocation ] );
const geolocationOverruled =
geolocatedLocation &&
getCountry( storeCountry.key ) !== getCountry( geolocationMatch.key );
const [ industry, setIndustry ] = useState<
IndustryChoiceOption | undefined
>(
industryFromOnboardingProfile
? industryChoices.find(
( choice ) =>
choice.key === industryFromOnboardingProfile[ 0 ]
)
: undefined
);
const selectCountryLabel = __( 'Select country/region', 'woocommerce' );
const selectIndustryQuestionLabel =
selectIndustryMapping[
businessChoice || 'im_just_starting_my_business'
];
const [ dismissedGeolocationNotice, setDismissedGeolocationNotice ] =
useState( false );
return (
<div
className="woocommerce-profiler-business-information"
data-testid="core-profiler-business-information"
>
<Navigation percentage={ navigationProgress } />
<div className="woocommerce-profiler-page__content woocommerce-profiler-business-information__content">
<Heading
className="woocommerce-profiler__stepper-heading"
title={ __(
'Tell us a bit about your store',
'woocommerce'
) }
subTitle={ __(
"We'll use this information to help you set up payments, shipping, and taxes, as well as recommending the best theme for your store.",
'woocommerce'
) }
/>
<form
className="woocommerce-profiler-business-information-form"
autoComplete="off"
>
<TextControl
className="woocommerce-profiler-business-info-store-name"
onChange={ ( value ) => {
setStoreName( value );
} }
value={ storeName }
label={
<>
{ __(
'Give your store a name',
'woocommerce'
) }
</>
}
placeholder={ __(
'Ex. My awesome store',
'woocommerce'
) }
/>
<p className="woocommerce-profiler-question-subtext">
{ __(
"Don't worry — you can always change it later!",
'woocommerce'
) }
</p>
<p className="woocommerce-profiler-question-label">
{ selectIndustryQuestionLabel }
</p>
<SelectControl
className="woocommerce-profiler-select-control__industry"
instanceId={ 1 }
placeholder={ __(
'Select an industry',
'woocommerce'
) }
label={ __( 'Select an industry', 'woocommerce' ) }
options={ industryChoices }
excludeSelectedOptions={ false }
help={ <Icon icon={ chevronDown } /> }
onChange={ (
results: Array< typeof industryChoices[ number ] >
) => {
if ( results.length ) {
setIndustry( results[ 0 ] );
}
} }
selected={ industry ? [ industry ] : [] }
showAllOnFocus
isSearchable
/>
<p className="woocommerce-profiler-question-label">
{ __( 'Where is your store located?', 'woocommerce' ) }
<span className="woocommerce-profiler-question-required">
{ '*' }
</span>
</p>
<SelectControl
className="woocommerce-profiler-select-control__country"
instanceId={ 2 }
placeholder={ selectCountryLabel }
label={
storeCountry.key === '' ? selectCountryLabel : ''
}
getSearchExpression={ ( query: string ) => {
return new RegExp(
'(^' + query + '| — (' + query + '))',
'i'
);
} }
options={ countries }
excludeSelectedOptions={ false }
help={ <Icon icon={ chevronDown } /> }
onChange={ ( results: Array< CountryStateOption > ) => {
if ( results.length ) {
setStoreCountry( results[ 0 ] );
}
} }
selected={ storeCountry ? [ storeCountry ] : [] }
showAllOnFocus
isSearchable
/>
{ /* woocommerce-profiler-select-control__country-spacer exists purely because the select-control above has an unremovable and unstyleable div and that's preventing margin collapse */ }
<div className="woocommerce-profiler-select-control__country-spacer" />
{ geolocationOverruled && ! dismissedGeolocationNotice && (
<Notice
className="woocommerce-profiler-geolocation-notice"
onRemove={ () =>
setDismissedGeolocationNotice( true )
}
status="warning"
>
<p>
{ createInterpolateElement(
__(
// translators: first tag is filled with the country name detected by geolocation, second tag is the country name selected by the user
"It looks like you're located in <geolocatedCountry></geolocatedCountry>. Are you sure you want to create a store in <selectedCountry></selectedCountry>?",
'woocommerce'
),
{
geolocatedCountry: (
<Button
className="geolocation-notice-geolocated-country"
variant="link"
onClick={ () =>
setStoreCountry(
geolocationMatch
)
}
>
{
geolocatedLocation?.country_long
}
</Button>
),
selectedCountry: (
<span className="geolocation-notice-selected-country">
{ storeCountry.label }
</span>
),
}
) }
</p>
<p>
{ __(
'Setting up your store in the wrong country may lead to the following issues: ',
'woocommerce'
) }
</p>
<ul className="woocommerce-profiler-geolocation-notice__list">
<li>
{ __(
'Tax and duty obligations',
'woocommerce'
) }
</li>
<li>
{ __( 'Payment issues', 'woocommerce' ) }
</li>
<li>
{ __( 'Shipping issues', 'woocommerce' ) }
</li>
</ul>
</Notice>
) }
</form>
<div className="woocommerce-profiler-button-container">
<Button
className="woocommerce-profiler-button"
variant="primary"
disabled={ ! storeCountry.key }
onClick={ () => {
sendEvent( {
type: 'BUSINESS_INFO_COMPLETED',
payload: {
storeName,
industry: industry?.key,
storeLocation: storeCountry.key,
geolocationOverruled:
geolocationOverruled || false,
},
} );
} }
>
{ __( 'Continue', 'woocommerce' ) }
</Button>
</div>
</div>
</div>
);
};

View File

@ -84,9 +84,7 @@ export const BusinessLocation = ( {
sendEvent( {
type: 'BUSINESS_LOCATION_COMPLETED',
payload: {
businessInfo: {
location: storeCountry.key,
},
storeLocation: storeCountry.key,
},
} );
} }

View File

@ -0,0 +1,308 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import { BusinessInfo } from '../BusinessInfo';
import { CoreProfilerStateMachineContext } from '../..';
describe( 'BusinessInfo', () => {
let props: {
sendEvent: jest.Mock;
navigationProgress: number;
context: CoreProfilerStateMachineContext;
};
beforeEach( () => {
props = {
sendEvent: jest.fn(),
navigationProgress: 0,
context: {
geolocatedLocation: undefined,
userProfile: {
businessChoice: 'im_just_starting_my_business',
},
businessInfo: {
storeName: '',
location: '',
},
countries: [
{
key: 'AU:VIC',
label: 'Australia — Victoria',
},
{
key: 'AW',
label: 'Aruba',
},
],
// @ts-expect-error -- we don't need the other props in the tests
onboardingProfile: {
is_store_country_set: false,
industry: [ 'other' ],
},
},
};
} );
it( 'should render business info page', () => {
render( <BusinessInfo { ...props } /> );
expect(
screen.getByText( /Tell us a bit about your store/i, {
selector: 'h1',
} )
).toBeInTheDocument();
expect(
screen.getByText( /Give your store a name/i )
).toBeInTheDocument();
expect(
screen.getByText( /Where is your store located?/i )
).toBeInTheDocument();
expect(
screen.getByRole( 'button', {
name: /Continue/i,
} )
).toBeInTheDocument();
} );
it( 'should show the correct label for each businessChoice', () => {
props.context.userProfile.businessChoice =
'im_just_starting_my_business';
render( <BusinessInfo { ...props } /> );
expect(
screen.getByText(
/What type of products or services do you plan to sell?/i
)
).toBeInTheDocument();
props.context.userProfile.businessChoice = 'im_already_selling';
render( <BusinessInfo { ...props } /> );
expect(
screen.getByText( /Which industry is your business in?/i )
).toBeInTheDocument();
props.context.userProfile.businessChoice =
'im_setting_up_a_store_for_a_client';
render( <BusinessInfo { ...props } /> );
expect(
screen.getByText( /Which industry is your client's business in?/i )
).toBeInTheDocument();
} );
it( 'should prepopulate the store country selector if geolocation is available', () => {
props.context.geolocatedLocation = {
latitude: '-37.83961',
longitude: '144.94228',
country_short: 'AU',
country_long: 'Australia',
region: 'Victoria',
city: 'Port Melbourne',
};
render( <BusinessInfo { ...props } /> );
expect(
screen.getByRole( 'combobox', {
name: '', // unfortunately the SelectControl loses its name when its populated and there's no other unique identifier
} )
).toHaveValue( 'Australia — Victoria' );
} );
it( 'should not prepopulate the store country selector if geolocation is not available', () => {
render( <BusinessInfo { ...props } /> );
expect(
screen.getByRole( 'combobox', {
name: /Select country\/region/i,
} )
).toHaveValue( '' );
} );
it( 'should correctly prepopulate the store name if it is passed in', () => {
props.context.businessInfo.storeName = 'Test Store Name';
render( <BusinessInfo { ...props } /> );
expect(
screen.getByRole( 'textbox', {
name: /Give your store a name/i,
} )
).toHaveValue( 'Test Store Name' );
} );
it( 'should correctly prepopulate the industry if it is passed in', () => {
props.context.onboardingProfile.industry = [ 'food_and_drink' ];
render( <BusinessInfo { ...props } /> );
expect(
screen.getByRole( 'combobox', {
name: /Select an industry/i,
} )
).toHaveValue( 'Food and drink' );
} );
it( 'should correctly prepopulate the store location if it is passed in', () => {
props.context.businessInfo.location = 'AW';
props.context.onboardingProfile.is_store_country_set = true;
render( <BusinessInfo { ...props } /> );
expect(
screen.getByRole( 'combobox', {
name: '',
} )
).toHaveValue( 'Aruba' );
} );
it( 'should correctly send event with empty form inputs when continue is clicked', () => {
props.context.geolocatedLocation = {
latitude: '-37.83961',
longitude: '144.94228',
country_short: 'AU',
country_long: 'Australia',
region: 'Victoria',
city: 'Port Melbourne',
};
render( <BusinessInfo { ...props } /> );
const continueButton = screen.getByRole( 'button', {
name: /Continue/i,
} );
userEvent.click( continueButton );
expect( props.sendEvent ).toHaveBeenCalledWith( {
payload: {
geolocationOverruled: false,
industry: 'other',
storeLocation: 'AU:VIC',
storeName: '',
},
type: 'BUSINESS_INFO_COMPLETED',
} );
} );
it( 'should show the warning tooltip if geolocation is available and the user has manually changed the country, and send the event with geolocationOverruled', async () => {
props.context.geolocatedLocation = {
latitude: '-37.83961',
longitude: '144.94228',
country_short: 'AU',
country_long: 'Australia',
region: 'Victoria',
city: 'Port Melbourne',
};
render( <BusinessInfo { ...props } /> );
const countrySelector = screen.getByRole( 'combobox', {
name: '',
} );
countrySelector.focus();
await waitFor( () => {
expect(
screen.getByRole( 'option', {
name: /Aruba/i,
} )
).toBeInTheDocument();
} );
await userEvent.click(
screen.getByRole( 'option', {
name: /Aruba/i,
} )
);
expect(
screen.getByText(
/Setting up your store in the wrong country may lead to the following issues: /i
)
).toBeInTheDocument();
const continueButton = screen.getByRole( 'button', {
name: /Continue/i,
} );
userEvent.click( continueButton );
expect( props.sendEvent ).toHaveBeenCalledWith( {
payload: {
geolocationOverruled: true,
industry: 'other',
storeLocation: 'AW',
storeName: '',
},
type: 'BUSINESS_INFO_COMPLETED',
} );
} );
it( 'should correctly send event with inputs filled in a fresh form when continue is clicked', async () => {
props.context.geolocatedLocation = {
latitude: '-37.83961',
longitude: '144.94228',
country_short: 'AU',
country_long: 'Australia',
region: 'Victoria',
city: 'Port Melbourne',
};
render( <BusinessInfo { ...props } /> );
const storeNameInput = screen.getByRole( 'textbox', {
name: /Give your store a name/i,
} );
userEvent.type( storeNameInput, 'Test Store Name' );
const industrySelector = screen.getByRole( 'combobox', {
name: /Select an industry/i,
} );
industrySelector.focus();
await waitFor( () => {
expect(
screen.getByRole( 'option', {
name: /Food and drink/i,
} )
).toBeInTheDocument();
} );
await userEvent.click(
screen.getByRole( 'option', {
name: /Food and drink/i,
} )
);
const continueButton = screen.getByRole( 'button', {
name: /Continue/i,
} );
userEvent.click( continueButton );
expect( props.sendEvent ).toHaveBeenCalledWith( {
payload: {
geolocationOverruled: false,
industry: 'food_and_drink',
storeLocation: 'AU:VIC',
storeName: 'Test Store Name',
},
type: 'BUSINESS_INFO_COMPLETED',
} );
} );
it( 'should send the event with the correct values if the form has been pre-filled from context', () => {
props.context.geolocatedLocation = {
latitude: '-37.83961',
longitude: '144.94228',
country_short: 'AU',
country_long: 'Australia',
region: 'Victoria',
city: 'Port Melbourne',
};
props.context.onboardingProfile.industry = [ 'food_and_drink' ];
props.context.businessInfo.storeName = 'Test Store Name';
props.context.businessInfo.location = 'AU:VIC';
render( <BusinessInfo { ...props } /> );
const continueButton = screen.getByRole( 'button', {
name: /Continue/i,
} );
userEvent.click( continueButton );
expect( props.sendEvent ).toHaveBeenCalledWith( {
payload: {
geolocationOverruled: false,
industry: 'food_and_drink',
storeLocation: 'AU:VIC',
storeName: 'Test Store Name',
},
type: 'BUSINESS_INFO_COMPLETED',
} );
} );
} );

View File

@ -12,15 +12,6 @@ export type CountryStateOption = {
label: string;
};
type UserLocation = {
latitude: string;
longitude: string;
country_short: string;
country_long: string;
region: string;
city: string;
};
/**
* Get all country and state combinations used for select dropdowns.
*
@ -58,33 +49,3 @@ export function getCountryStateOptions(
return countryStateOptions;
}
/**
* Get the user's location
*
* @return {Object} {
* latitude: '39.039474',
* longitude: '-77.491809',
* country_short: 'US',
* country_long: 'United States of America',
* region: 'Virginia',
* city: 'Ashburn'
* }
*/
export const getUserLocation = (): Promise< UserLocation | null > => {
// cache buster
const v = new Date().getTime();
return fetch( 'https://public-api.wordpress.com/geo/?v=' + v )
.then( ( res ) => {
if ( ! res.ok ) {
// @ts-expect-error tmp
return res.body().then( ( body ) => {
throw new Error( body );
} );
}
return res.json();
} )
.catch( () => {
return null;
} );
};

View File

@ -33,6 +33,7 @@
position: absolute;
bottom: 20px;
padding: 0 20px;
max-width: 100%;
}
}
@ -457,3 +458,141 @@
}
}
}
// Business Info Page
.woocommerce-profiler-business-information {
display: flex;
flex-direction: column;
.woocommerce-profiler-business-information__content {
max-width: 615px;
width: 100%;
display: flex;
align-self: center;
& > div {
width: 100%;
}
.woocommerce-profiler-business-information-form {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
max-width: 404px;
width: 100%;
}
.woocommerce-profiler-question-label,
.woocommerce-profiler-business-info-store-name
.components-base-control__label {
text-transform: uppercase;
color: $gray-900;
font-weight: 500;
font-size: 11px;
line-height: 16px;
margin: 0 0 8px;
}
.woocommerce-profiler-question-label
.woocommerce-profiler-question-required {
color: #cc1818;
padding-left: 3px;
}
.woocommerce-profiler-business-info-store-name
.components-text-control__input {
height: 40px;
border-color: #bbb;
border-radius: 2px;
border-width: 1px;
}
.woocommerce-profiler-select-control__industry {
margin-bottom: 20px;
}
.woocommerce-profiler-question-subtext {
color: $gray-700;
font-size: 12px;
line-height: 16px;
font-weight: 400;
margin-top: 0;
margin-bottom: 20px;
}
.woocommerce-profiler-select-control__country {
max-width: 404px;
margin: 0;
}
.woocommerce-profiler-select-control__country-spacer {
margin: auto auto 12px auto;
}
.woocommerce-profiler-button-container {
margin-top: 32px;
}
.woocommerce-select-control__control-icon {
display: none;
}
.woocommerce-select-control__control.is-active {
.components-base-control__label {
display: none;
}
}
.woocommerce-select-control.is-searchable
.woocommerce-select-control__control-input {
margin: 0;
padding: 0;
}
.woocommerce-select-control.is-searchable
.components-base-control__label {
left: 13px;
}
.woocommerce-profiler-geolocation-notice {
margin: 0;
margin-bottom: 0;
padding-right: 0;
color: $gray-900;
@include breakpoint( '<782px' ) {
margin-bottom: 100px;
}
.components-notice__dismiss > svg {
height: 16px;
fill: #000;
}
.components-notice__dismiss {
height: 16px;
margin-right: 8px;
}
p {
font-weight: 400;
font-size: 13px;
line-height: 20px;
margin: 0;
.geolocation-notice-geolocated-country {
color: #3858e9;
text-decoration: none;
}
}
.woocommerce-profiler-geolocation-notice__list {
list-style-type: unset;
padding-left: 20px;
font-weight: 500;
line-height: 20px;
}
}
}
}

View File

@ -12,20 +12,29 @@ import { createMachine } from 'xstate';
/**
* Internal dependencies
*/
import { CoreProfilerController } from '../';
import {
preFetchActions,
recordTracksActions,
CoreProfilerController,
} from '../';
const preFetchActionsMocks = Object.fromEntries(
Object.entries( preFetchActions ).map( ( [ key ] ) => [ key, jest.fn() ] )
);
const recordTracksActionsMocks = Object.fromEntries(
Object.entries( recordTracksActions ).map( ( [ key ] ) => [
key,
jest.fn(),
] )
);
// mock out the external dependencies which we don't want to test here
const actionOverrides = {
...preFetchActionsMocks,
...recordTracksActionsMocks,
updateTrackingOption: jest.fn(),
updateOnboardingProfileOption: jest.fn(),
recordTracksIntroCompleted: jest.fn(),
recordTracksIntroSkipped: jest.fn(),
recordTracksIntroViewed: jest.fn(),
recordTracksUserProfileCompleted: jest.fn(),
recordTracksUserProfileSkipped: jest.fn(),
recordTracksUserProfileViewed: jest.fn(),
recordTracksSkipBusinessLocationViewed: jest.fn(),
recordTracksSkipBusinessLocationCompleted: jest.fn(),
redirectToWooHome: jest.fn(),
};
@ -36,6 +45,7 @@ const servicesOverrides = {
.mockResolvedValue( [
{ code: 'US', name: 'United States', states: [] },
] ),
getGeolocation: jest.fn().mockResolvedValue( {} ),
getOnboardingProfileOption: jest.fn().mockResolvedValue( {} ),
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Added business info page to new core profiler

View File

@ -206,6 +206,9 @@ class Options extends \WC_REST_Data_Controller {
'woocommerce_date_type',
'date_format',
'time_format',
'woocommerce_onboarding_profile',
'woocommerce_default_country',
'blogname',
// WC Test helper options.
'wc-admin-test-helper-rest-api-filters',
'wc_admin_helper_feature_values',