diff --git a/packages/js/data/changelog/add-geolocation-api-call b/packages/js/data/changelog/add-geolocation-api-call new file mode 100644 index 00000000000..3aa3715e854 --- /dev/null +++ b/packages/js/data/changelog/add-geolocation-api-call @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add ability to make geolocation call to WPCOM API diff --git a/packages/js/data/src/countries/action-types.ts b/packages/js/data/src/countries/action-types.ts index 68547991c51..25a739f1c17 100644 --- a/packages/js/data/src/countries/action-types.ts +++ b/packages/js/data/src/countries/action-types.ts @@ -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; diff --git a/packages/js/data/src/countries/actions.ts b/packages/js/data/src/countries/actions.ts index 7970d6bfd2e..af2b808eff5 100644 --- a/packages/js/data/src/countries/actions.ts +++ b/packages/js/data/src/countries/actions.ts @@ -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 >; diff --git a/packages/js/data/src/countries/reducer.ts b/packages/js/data/src/countries/reducer.ts index 48ab31815bb..f95e1dffeb4 100644 --- a/packages/js/data/src/countries/reducer.ts +++ b/packages/js/data/src/countries/reducer.ts @@ -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; }; diff --git a/packages/js/data/src/countries/resolvers.ts b/packages/js/data/src/countries/resolvers.ts index ccdb261276e..8f8afde66c9 100644 --- a/packages/js/data/src/countries/resolvers.ts +++ b/packages/js/data/src/countries/resolvers.ts @@ -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 ); + } + }; diff --git a/packages/js/data/src/countries/selectors.ts b/packages/js/data/src/countries/selectors.ts index 13372f8e5b5..e9a81e4a7cc 100644 --- a/packages/js/data/src/countries/selectors.ts +++ b/packages/js/data/src/countries/selectors.ts @@ -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; +}; diff --git a/packages/js/data/src/countries/types.ts b/packages/js/data/src/countries/types.ts index 4d6eb2b849a..a107c4ac94a 100644 --- a/packages/js/data/src/countries/types.ts +++ b/packages/js/data/src/countries/types.ts @@ -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; }; diff --git a/packages/js/onboarding/changelog/add-get-country-util b/packages/js/onboarding/changelog/add-get-country-util new file mode 100644 index 00000000000..435f5738ec0 --- /dev/null +++ b/packages/js/onboarding/changelog/add-get-country-util @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added getCountry utility for splitting colon delimited country:state strings \ No newline at end of file diff --git a/packages/js/onboarding/src/utils/countries/index.ts b/packages/js/onboarding/src/utils/countries/index.ts index 387b0ee6d95..edc8664e883 100644 --- a/packages/js/onboarding/src/utils/countries/index.ts +++ b/packages/js/onboarding/src/utils/countries/index.ts @@ -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; diff --git a/packages/js/onboarding/src/utils/countries/tests/utils/index.test.ts b/packages/js/onboarding/src/utils/countries/tests/utils/index.test.ts index cb3546d99ec..688a100abff 100644 --- a/packages/js/onboarding/src/utils/countries/tests/utils/index.test.ts +++ b/packages/js/onboarding/src/utils/countries/tests/utils/index.test.ts @@ -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' ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/core-profiler/components/heading/style.scss b/plugins/woocommerce-admin/client/core-profiler/components/heading/style.scss index c1cf617130f..2a06768136c 100644 --- a/plugins/woocommerce-admin/client/core-profiler/components/heading/style.scss +++ b/plugins/woocommerce-admin/client/core-profiler/components/heading/style.scss @@ -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; diff --git a/plugins/woocommerce-admin/client/core-profiler/index.tsx b/plugins/woocommerce-admin/client/core-profiler/index.tsx index 186ffe9b54e..d0b9f8eb8ca 100644 --- a/plugins/woocommerce-admin/client/core-profiler/index.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/index.tsx @@ -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 ] diff --git a/plugins/woocommerce-admin/client/core-profiler/pages/BusinessInfo.tsx b/plugins/woocommerce-admin/client/core-profiler/pages/BusinessInfo.tsx index 103b97b852f..44539ef9a9d 100644 --- a/plugins/woocommerce-admin/client/core-profiler/pages/BusinessInfo.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/pages/BusinessInfo.tsx @@ -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 ( - <> -
Business Info
-
{ context.geolocatedLocation.location }
- - + } + } + }, [ 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 ( +
+ +
+ + +
+ { + setStoreName( value ); + } } + value={ storeName } + label={ + <> + { __( + 'Give your store a name', + 'woocommerce' + ) } + + } + placeholder={ __( + 'Ex. My awesome store', + 'woocommerce' + ) } + /> +

+ { __( + "Don't worry — you can always change it later!", + 'woocommerce' + ) } +

+

+ { selectIndustryQuestionLabel } +

+ } + onChange={ ( + results: Array< typeof industryChoices[ number ] > + ) => { + if ( results.length ) { + setIndustry( results[ 0 ] ); + } + } } + selected={ industry ? [ industry ] : [] } + showAllOnFocus + isSearchable + /> +

+ { __( 'Where is your store located?', 'woocommerce' ) } + + { '*' } + +

+ { + return new RegExp( + '(^' + query + '| — (' + query + '))', + 'i' + ); + } } + options={ countries } + excludeSelectedOptions={ false } + help={ } + 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 */ } +
+ { geolocationOverruled && ! dismissedGeolocationNotice && ( + + setDismissedGeolocationNotice( true ) + } + status="warning" + > +

+ { 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 . Are you sure you want to create a store in ?", + 'woocommerce' + ), + { + geolocatedCountry: ( + + ), + selectedCountry: ( + + { storeCountry.label } + + ), + } + ) } +

+

+ { __( + 'Setting up your store in the wrong country may lead to the following issues: ', + 'woocommerce' + ) } +

+
    +
  • + { __( + 'Tax and duty obligations', + 'woocommerce' + ) } +
  • +
  • + { __( 'Payment issues', 'woocommerce' ) } +
  • +
  • + { __( 'Shipping issues', 'woocommerce' ) } +
  • +
+
+ ) } + +
+ +
+
+
); }; diff --git a/plugins/woocommerce-admin/client/core-profiler/pages/BusinessLocation.tsx b/plugins/woocommerce-admin/client/core-profiler/pages/BusinessLocation.tsx index 3c5fcf2bdbe..229f28be3f2 100644 --- a/plugins/woocommerce-admin/client/core-profiler/pages/BusinessLocation.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/pages/BusinessLocation.tsx @@ -84,9 +84,7 @@ export const BusinessLocation = ( { sendEvent( { type: 'BUSINESS_LOCATION_COMPLETED', payload: { - businessInfo: { - location: storeCountry.key, - }, + storeLocation: storeCountry.key, }, } ); } } diff --git a/plugins/woocommerce-admin/client/core-profiler/pages/tests/business-info.test.tsx b/plugins/woocommerce-admin/client/core-profiler/pages/tests/business-info.test.tsx new file mode 100644 index 00000000000..1a488f945e8 --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/pages/tests/business-info.test.tsx @@ -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( ); + 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( ); + expect( + screen.getByText( + /What type of products or services do you plan to sell?/i + ) + ).toBeInTheDocument(); + + props.context.userProfile.businessChoice = 'im_already_selling'; + render( ); + expect( + screen.getByText( /Which industry is your business in?/i ) + ).toBeInTheDocument(); + + props.context.userProfile.businessChoice = + 'im_setting_up_a_store_for_a_client'; + render( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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', + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/core-profiler/services/country.ts b/plugins/woocommerce-admin/client/core-profiler/services/country.ts index d55ef6b3881..a4016c8ab99 100644 --- a/plugins/woocommerce-admin/client/core-profiler/services/country.ts +++ b/plugins/woocommerce-admin/client/core-profiler/services/country.ts @@ -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; - } ); -}; diff --git a/plugins/woocommerce-admin/client/core-profiler/style.scss b/plugins/woocommerce-admin/client/core-profiler/style.scss index 0b5329f59a8..f3de578b0d8 100644 --- a/plugins/woocommerce-admin/client/core-profiler/style.scss +++ b/plugins/woocommerce-admin/client/core-profiler/style.scss @@ -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; + } + } + } +} diff --git a/plugins/woocommerce-admin/client/core-profiler/test/core-profiler-machine.test.tsx b/plugins/woocommerce-admin/client/core-profiler/test/core-profiler-machine.test.tsx index d2bcbaa57ca..ac4f29e4f16 100644 --- a/plugins/woocommerce-admin/client/core-profiler/test/core-profiler-machine.test.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/test/core-profiler-machine.test.tsx @@ -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( {} ), }; diff --git a/plugins/woocommerce/changelog/add-core-profiler-business-info b/plugins/woocommerce/changelog/add-core-profiler-business-info new file mode 100644 index 00000000000..181cec07840 --- /dev/null +++ b/plugins/woocommerce/changelog/add-core-profiler-business-info @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added business info page to new core profiler diff --git a/plugins/woocommerce/src/Admin/API/Options.php b/plugins/woocommerce/src/Admin/API/Options.php index 7298700a4f1..2d141163598 100644 --- a/plugins/woocommerce/src/Admin/API/Options.php +++ b/plugins/woocommerce/src/Admin/API/Options.php @@ -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',