* Add email address field to store details step in OBW (https://github.com/woocommerce/woocommerce-admin/pull/7552)

* Subscribe store_email to MailChimp (https://github.com/woocommerce/woocommerce-admin/pull/7579)

* Add prefill for email field in OBW (https://github.com/woocommerce/woocommerce-admin/pull/7570)

* Add error handling for email validation errors from backend (https://github.com/woocommerce/woocommerce-admin/pull/7590)

* Remove OnboardingEmailMarketing note class (https://github.com/woocommerce/woocommerce-admin/pull/7595)

Co-authored-by: Ilyas Foo <foo.ilyas@gmail.com>
Co-authored-by: Moon <moon.kyong@automattic.com>
This commit is contained in:
Adrian Duffell 2021-08-31 12:39:04 +08:00 committed by GitHub
parent ccdd32282d
commit 6d23ab7ea1
26 changed files with 653 additions and 103 deletions

View File

@ -2,6 +2,18 @@
## Unreleased ## Unreleased
### Add Newsletter Signup #7601
- Start OBW and set up your browser console to monitor tracks. To do this, run `localStorage.setItem( 'debug', 'wc-admin:*' );`
- Observe "Get tips, product updates and inspiration straight to your mailbox" checkbox and "Email address" field in the Store Details step.
- Checking the checkbox should make the email field required, you should not be able to continue if it's not filled.
- Fill in the email address field with a valid email and click on continue.
- Observe in the track `wcadmin_storeprofiler_store_details_continue` with prop `email_signup` that appropriately flags if the user agreed to receive marketing emails.
- Continue until Business Features step.
- Observe the "I'm setting up a store for a client" checkbox in the step.
- Click on continue.
- Observe in the track `wcadmin_storeprofiler_store_business_details_continue_variant` with prop `setup_client` that appropriately flags if the user is setting up store for a client.
### Making business details sticky in OBW #7426 ### Making business details sticky in OBW #7426
1. Start out with a fresh store 1. Start out with a fresh store

View File

@ -0,0 +1,4 @@
Significance: minor
Type: Dev
Add email address field to OBW #7552

View File

@ -0,0 +1,4 @@
Significance: minor
Type: Add
Added MailchimpScheduler that runs daily to subscribe store_email in the profile data #7579

View File

@ -0,0 +1,4 @@
Significance: minor
Type: Update
Deleted OnboardingEmailMarketing note class #7595

View File

@ -11,6 +11,8 @@ import {
CardFooter, CardFooter,
TabPanel, TabPanel,
__experimentalText as Text, __experimentalText as Text,
FlexItem,
CheckboxControl,
} from '@wordpress/components'; } from '@wordpress/components';
import { withDispatch, withSelect } from '@wordpress/data'; import { withDispatch, withSelect } from '@wordpress/data';
import { SelectControl, Form, TextControl } from '@woocommerce/components'; import { SelectControl, Form, TextControl } from '@woocommerce/components';
@ -138,6 +140,7 @@ class BusinessDetails extends Component {
product_count: productCount, product_count: productCount,
revenue, revenue,
selling_venues: sellingVenues, selling_venues: sellingVenues,
setup_client: isSetupClient,
} = this.state.savedValues; } = this.state.savedValues;
const updates = { const updates = {
@ -147,6 +150,7 @@ class BusinessDetails extends Component {
product_count: productCount, product_count: productCount,
revenue, revenue,
selling_venues: sellingVenues, selling_venues: sellingVenues,
setup_client: isSetupClient,
...additions, ...additions,
}; };
@ -242,6 +246,7 @@ class BusinessDetails extends Component {
product_count: productCount, product_count: productCount,
selling_venues: sellingVenues, selling_venues: sellingVenues,
revenue, revenue,
setup_client: isSetupClient,
} ) { } ) {
const { getCurrencyConfig } = this.context; const { getCurrencyConfig } = this.context;
@ -252,6 +257,7 @@ class BusinessDetails extends Component {
revenue, revenue,
used_platform: otherPlatform, used_platform: otherPlatform,
used_platform_name: otherPlatformName, used_platform_name: otherPlatformName,
setup_client: isSetupClient,
} ); } );
} }
@ -392,7 +398,22 @@ class BusinessDetails extends Component {
</> </>
) } ) }
</CardBody> </CardBody>
<CardFooter isBorderless justify="center"> <CardFooter isBorderless>
<FlexItem>
<div className="woocommerce-profile-wizard__client">
<CheckboxControl
label={ __(
"I'm setting up a store for a client",
'woocommerce-admin'
) }
{ ...getInputProps(
'setup_client'
) }
/>
</div>
</FlexItem>
</CardFooter>
<CardFooter justify="center">
<Button <Button
isPrimary isPrimary
onClick={ async () => { onClick={ async () => {

View File

@ -18,8 +18,5 @@
&__body { &__body {
padding: $gap $gap 0; padding: $gap $gap 0;
} }
&__footer {
padding-top: 0;
}
} }
} }

View File

@ -39,6 +39,7 @@ export const BusinessDetailsStep = ( props ) => {
product_count: profileItems.product_count || '', product_count: profileItems.product_count || '',
selling_venues: profileItems.selling_venues || '', selling_venues: profileItems.selling_venues || '',
revenue: profileItems.revenue || '', revenue: profileItems.revenue || '',
setup_client: profileItems.setup_client || false,
}; };
return ( return (

View File

@ -9,12 +9,13 @@ import {
CardFooter, CardFooter,
CheckboxControl, CheckboxControl,
FlexItem as MaybeFlexItem, FlexItem as MaybeFlexItem,
Spinner,
Popover, Popover,
} from '@wordpress/components'; } from '@wordpress/components';
import { Component } from '@wordpress/element'; import { Component, useRef } from '@wordpress/element';
import { compose } from '@wordpress/compose'; import { compose } from '@wordpress/compose';
import { withDispatch, withSelect } from '@wordpress/data'; import { withDispatch, withSelect } from '@wordpress/data';
import { Form } from '@woocommerce/components'; import { Form, TextControl } from '@woocommerce/components';
import { getSetting } from '@woocommerce/wc-admin-settings'; import { getSetting } from '@woocommerce/wc-admin-settings';
import { import {
ONBOARDING_STORE_NAME, ONBOARDING_STORE_NAME,
@ -48,10 +49,15 @@ const FlextItemSubstitute = ( { children, align } ) => {
}; };
const FlexItem = MaybeFlexItem || FlextItemSubstitute; const FlexItem = MaybeFlexItem || FlextItemSubstitute;
const LoadingPlaceholder = () => (
<div className="woocommerce-admin__store-details__spinner">
<Spinner />
</div>
);
class StoreDetails extends Component { class StoreDetails extends Component {
constructor( props ) { constructor( props ) {
super( props ); super( props );
const { profileItems, settings } = props;
this.state = { this.state = {
showUsageModal: false, showUsageModal: false,
@ -60,22 +66,6 @@ class StoreDetails extends Component {
isSkipSetupPopoverVisible: false, isSkipSetupPopoverVisible: false,
}; };
// Check if a store address is set so that we don't default
// to WooCommerce's default country of the UK.
const countryState =
( settings.woocommerce_store_address &&
settings.woocommerce_default_country ) ||
'';
this.initialValues = {
addressLine1: settings.woocommerce_store_address || '',
addressLine2: settings.woocommerce_store_address_2 || '',
city: settings.woocommerce_store_city || '',
countryState,
postCode: settings.woocommerce_store_postcode || '',
isClient: profileItems.setup_client || false,
};
this.onContinue = this.onContinue.bind( this ); this.onContinue = this.onContinue.bind( this );
this.onSubmit = this.onSubmit.bind( this ); this.onSubmit = this.onSubmit.bind( this );
} }
@ -109,12 +99,11 @@ class StoreDetails extends Component {
const { const {
createNotice, createNotice,
goToNextStep, goToNextStep,
isSettingsError,
updateProfileItems, updateProfileItems,
isProfileItemsError,
updateAndPersistSettingsForGroup, updateAndPersistSettingsForGroup,
profileItems, profileItems,
settings, settings,
errorsRef,
} = this.props; } = this.props;
const currencySettings = this.deriveCurrencySettings( const currencySettings = this.deriveCurrencySettings(
@ -126,7 +115,7 @@ class StoreDetails extends Component {
recordEvent( 'storeprofiler_store_details_continue', { recordEvent( 'storeprofiler_store_details_continue', {
store_country: getCountryCode( values.countryState ), store_country: getCountryCode( values.countryState ),
derived_currency: currencySettings.currency_code, derived_currency: currencySettings.currency_code,
setup_client: values.isClient, email_signup: values.isAgreeMarketing,
} ); } );
await updateAndPersistSettingsForGroup( 'general', { await updateAndPersistSettingsForGroup( 'general', {
@ -147,7 +136,11 @@ class StoreDetails extends Component {
}, },
} ); } );
const profileItemsToUpdate = { setup_client: values.isClient }; const profileItemsToUpdate = {
is_agree_marketing: values.isAgreeMarketing,
store_email: values.storeEmail,
};
const region = getCurrencyRegion( values.countryState ); const region = getCurrencyRegion( values.countryState );
/** /**
@ -174,9 +167,20 @@ class StoreDetails extends Component {
profileItemsToUpdate.industry = trimmedIndustries; profileItemsToUpdate.industry = trimmedIndustries;
} }
await updateProfileItems( profileItemsToUpdate ); let errorMessages = [];
try {
await updateProfileItems( profileItemsToUpdate );
} catch ( error ) {
// Array of error messages obtained from API response.
if ( error?.data?.params ) {
errorMessages = Object.values( error.data.params );
}
}
if ( ! isSettingsError && ! isProfileItemsError ) { if (
! Boolean( errorsRef.current.settings ) &&
! errorMessages.length
) {
goToNextStep(); goToNextStep();
} else { } else {
createNotice( createNotice(
@ -186,9 +190,39 @@ class StoreDetails extends Component {
'woocommerce-admin' 'woocommerce-admin'
) )
); );
errorMessages.forEach( ( message ) =>
createNotice( 'error', message )
);
} }
} }
validateStoreDetails( values ) {
const errors = validateStoreAddress( values );
if (
values.isAgreeMarketing &&
( ! values.storeEmail || ! values.storeEmail.trim().length )
) {
errors.storeEmail = __(
'Please add an email address',
'woocommerce-admin'
);
}
if (
values.storeEmail &&
values.storeEmail.trim().length &&
values.storeEmail.indexOf( '@' ) === -1
) {
errors.storeEmail = __(
'Invalid email address',
'woocommerce-admin'
);
}
return errors;
}
render() { render() {
const { const {
showUsageModal, showUsageModal,
@ -196,7 +230,7 @@ class StoreDetails extends Component {
isStoreDetailsPopoverVisible, isStoreDetailsPopoverVisible,
isSkipSetupPopoverVisible, isSkipSetupPopoverVisible,
} = this.state; } = this.state;
const { skipProfiler, isBusy } = this.props; const { skipProfiler, isLoading, isBusy, initialValues } = this.props;
/* eslint-disable @wordpress/i18n-no-collapsible-whitespace */ /* eslint-disable @wordpress/i18n-no-collapsible-whitespace */
const skipSetupText = __( const skipSetupText = __(
@ -210,6 +244,14 @@ class StoreDetails extends Component {
); );
/* eslint-enable @wordpress/i18n-no-collapsible-whitespace */ /* eslint-enable @wordpress/i18n-no-collapsible-whitespace */
if ( isLoading ) {
return (
<div className="woocommerce-profile-wizard__store-details">
<LoadingPlaceholder />
</div>
);
}
return ( return (
<div className="woocommerce-profile-wizard__store-details"> <div className="woocommerce-profile-wizard__store-details">
<div className="woocommerce-profile-wizard__step-header"> <div className="woocommerce-profile-wizard__step-header">
@ -258,9 +300,9 @@ class StoreDetails extends Component {
</div> </div>
<Form <Form
initialValues={ this.initialValues } initialValues={ initialValues }
onSubmit={ this.onSubmit } onSubmit={ this.onSubmit }
validate={ validateStoreAddress } validate={ this.validateStoreDetails }
> >
{ ( { { ( {
getInputProps, getInputProps,
@ -292,17 +334,29 @@ class StoreDetails extends Component {
getInputProps={ getInputProps } getInputProps={ getInputProps }
setValue={ setValue } setValue={ setValue }
/> />
<TextControl
label={ __(
'Email address',
'woocommerce-admin'
) }
required
autoComplete="email"
{ ...getInputProps( 'storeEmail' ) }
/>
</CardBody> </CardBody>
<CardFooter> <CardFooter>
<FlexItem> <FlexItem>
<div className="woocommerce-profile-wizard__client"> <div>
<CheckboxControl <CheckboxControl
label={ __( label={ __(
"I'm setting up a store for a client", 'Get tips, product updates and inspiration straight to your mailbox',
'woocommerce-admin' 'woocommerce-admin'
) } ) }
{ ...getInputProps( 'isClient' ) } { ...getInputProps(
'isAgreeMarketing'
) }
/> />
</div> </div>
</FlexItem> </FlexItem>
@ -376,30 +430,59 @@ export default compose(
isUpdateSettingsRequesting, isUpdateSettingsRequesting,
} = select( SETTINGS_STORE_NAME ); } = select( SETTINGS_STORE_NAME );
const { const {
getOnboardingError,
getProfileItems, getProfileItems,
isOnboardingRequesting, isOnboardingRequesting,
getEmailPrefill,
hasFinishedResolution: hasFinishedResolutionOnboarding,
} = select( ONBOARDING_STORE_NAME ); } = select( ONBOARDING_STORE_NAME );
const { isResolving } = select( OPTIONS_STORE_NAME ); const { isResolving } = select( OPTIONS_STORE_NAME );
const profileItems = getProfileItems(); const profileItems = getProfileItems();
const isProfileItemsError = Boolean(
getOnboardingError( 'updateProfileItems' )
);
const { general: settings = {} } = getSettings( 'general' ); const { general: settings = {} } = getSettings( 'general' );
const isSettingsError = Boolean( getSettingsError( 'general' ) );
const isBusy = const isBusy =
isOnboardingRequesting( 'updateProfileItems' ) || isOnboardingRequesting( 'updateProfileItems' ) ||
isUpdateSettingsRequesting( 'general' ) || isUpdateSettingsRequesting( 'general' ) ||
isResolving( 'getOption', [ 'woocommerce_allow_tracking' ] ); isResolving( 'getOption', [ 'woocommerce_allow_tracking' ] );
const isLoading =
! hasFinishedResolutionOnboarding( 'getProfileItems' ) ||
! hasFinishedResolutionOnboarding( 'getEmailPrefill' );
const errorsRef = useRef( {
settings: null,
} );
errorsRef.current = {
settings: getSettingsError( 'general' ),
};
// Check if a store address is set so that we don't default
// to WooCommerce's default country of the UK.
const countryState =
( settings.woocommerce_store_address &&
settings.woocommerce_default_country ) ||
'';
const initialValues = {
addressLine1: settings.woocommerce_store_address || '',
addressLine2: settings.woocommerce_store_address_2 || '',
city: settings.woocommerce_store_city || '',
countryState,
postCode: settings.woocommerce_store_postcode || '',
isAgreeMarketing:
typeof profileItems.is_agree_marketing === 'boolean'
? profileItems.is_agree_marketing
: true,
storeEmail:
typeof profileItems.store_email === 'string'
? profileItems.store_email
: getEmailPrefill(),
};
return { return {
isProfileItemsError, initialValues,
isSettingsError, isLoading,
profileItems, profileItems,
isBusy, isBusy,
settings, settings,
errorsRef,
}; };
} ), } ),
withDispatch( ( dispatch ) => { withDispatch( ( dispatch ) => {

View File

@ -1,4 +1,9 @@
.woocommerce-profile-wizard__store-details { .woocommerce-profile-wizard__store-details {
.woocommerce-admin__store-details__spinner {
display: flex;
justify-content: center;
}
.components-popover .components-popover__content { .components-popover .components-popover__content {
min-width: 360px; min-width: 360px;
} }

View File

@ -193,10 +193,6 @@
} }
} }
.woocommerce-profile-wizard__client {
margin: $gap-smaller 0;
}
.woocommerce-profile-wizard__checkbox { .woocommerce-profile-wizard__checkbox {
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;

View File

@ -5,7 +5,12 @@ import { BasePage } from '../../pages/BasePage';
import { waitForElementByText } from '../../utils/actions'; import { waitForElementByText } from '../../utils/actions';
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const { setCheckbox, unsetCheckbox } = require( '@woocommerce/e2e-utils' ); const {
setCheckbox,
unsetCheckbox,
verifyCheckboxIsSet,
verifyCheckboxIsUnset,
} = require( '@woocommerce/e2e-utils' );
/* eslint-enable @typescript-eslint/no-var-requires */ /* eslint-enable @typescript-eslint/no-var-requires */
export class BusinessSection extends BasePage { export class BusinessSection extends BasePage {
@ -66,4 +71,18 @@ export class BusinessSection extends BasePage {
'.woocommerce-profile-wizard__benefit .components-form-toggle__input' '.woocommerce-profile-wizard__benefit .components-form-toggle__input'
); );
} }
async selectSetupForClient() {
await setCheckbox( '.components-checkbox-control__input' );
}
async checkClientSetupCheckbox( selected: boolean ) {
if ( selected ) {
await verifyCheckboxIsSet( '.components-checkbox-control__input' );
} else {
await verifyCheckboxIsUnset(
'.components-checkbox-control__input'
);
}
}
} }

View File

@ -3,10 +3,10 @@
*/ */
import { DropdownTypeaheadField } from '../../elements/DropdownTypeaheadField'; import { DropdownTypeaheadField } from '../../elements/DropdownTypeaheadField';
import { BasePage } from '../../pages/BasePage'; import { BasePage } from '../../pages/BasePage';
import { waitForElementByText } from '../../utils/actions';
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const { const {
setCheckbox,
clearAndFillInput, clearAndFillInput,
verifyCheckboxIsSet, verifyCheckboxIsSet,
verifyCheckboxIsUnset, verifyCheckboxIsUnset,
@ -22,6 +22,7 @@ interface StoreDetails {
countryRegion?: string; countryRegion?: string;
city?: string; city?: string;
postcode?: string; postcode?: string;
storeEmail?: string;
} }
export class StoreDetailsSection extends BasePage { export class StoreDetailsSection extends BasePage {
@ -29,6 +30,10 @@ export class StoreDetailsSection extends BasePage {
return this.getDropdownTypeahead( '#woocommerce-select-control' ); return this.getDropdownTypeahead( '#woocommerce-select-control' );
} }
async isDisplayed() {
await waitForElementByText( 'h2', 'Welcome to WooCommerce' );
}
async completeStoreDetailsSection( storeDetails: StoreDetails = {} ) { async completeStoreDetailsSection( storeDetails: StoreDetails = {} ) {
// const onboardingWizard = new OnboardingWizard( page ); // const onboardingWizard = new OnboardingWizard( page );
// Fill store's address - first line // Fill store's address - first line
@ -66,8 +71,14 @@ export class StoreDetailsSection extends BasePage {
config.get( 'addresses.admin.store.postcode' ) config.get( 'addresses.admin.store.postcode' )
); );
// Verify that checkbox next to "I'm setting up a store for a client" is not selected // Fill store's email address
await this.checkClientSetupCheckbox( false ); await this.fillEmailAddress(
storeDetails.storeEmail ||
config.get( 'addresses.admin.store.email' )
);
// Verify that checkbox next to "Get tips, product updates and inspiration straight to your mailbox" is selected
await this.checkMarketingCheckbox( true );
} }
async fillAddress( address: string ) { async fillAddress( address: string ) {
@ -95,11 +106,11 @@ export class StoreDetailsSection extends BasePage {
await clearAndFillInput( '#inspector-text-control-3', postalCode ); await clearAndFillInput( '#inspector-text-control-3', postalCode );
} }
async selectSetupForClient() { async fillEmailAddress( email: string ) {
await setCheckbox( '.components-checkbox-control__input' ); await clearAndFillInput( '#inspector-text-control-4', email );
} }
async checkClientSetupCheckbox( selected: boolean ) { async checkMarketingCheckbox( selected: boolean ) {
if ( selected ) { if ( selected ) {
await verifyCheckboxIsSet( '.components-checkbox-control__input' ); await verifyCheckboxIsSet( '.components-checkbox-control__input' );
} else { } else {

View File

@ -37,6 +37,7 @@ const testAdminOnboardingWizard = () => {
} ); } );
it( 'can complete the store details section', async () => { it( 'can complete the store details section', async () => {
await profileWizard.storeDetails.isDisplayed();
await profileWizard.storeDetails.completeStoreDetailsSection(); await profileWizard.storeDetails.completeStoreDetailsSection();
// Wait for "Continue" button to become active // Wait for "Continue" button to become active
await profileWizard.continue(); await profileWizard.continue();
@ -81,7 +82,7 @@ const testAdminOnboardingWizard = () => {
await profileWizard.business.selectCurrentlySelling( await profileWizard.business.selectCurrentlySelling(
config.get( 'onboardingwizard.sellingelsewhere' ) config.get( 'onboardingwizard.sellingelsewhere' )
); );
await profileWizard.business.checkClientSetupCheckbox( false );
await profileWizard.continue(); await profileWizard.continue();
} ); } );

View File

@ -2,6 +2,7 @@ const TYPES = {
SET_ERROR: 'SET_ERROR', SET_ERROR: 'SET_ERROR',
SET_IS_REQUESTING: 'SET_IS_REQUESTING', SET_IS_REQUESTING: 'SET_IS_REQUESTING',
SET_PROFILE_ITEMS: 'SET_PROFILE_ITEMS', SET_PROFILE_ITEMS: 'SET_PROFILE_ITEMS',
SET_EMAIL_PREFILL: 'SET_EMAIL_PREFILL',
SET_TASKS_STATUS: 'SET_TASKS_STATUS', SET_TASKS_STATUS: 'SET_TASKS_STATUS',
GET_PAYMENT_METHODS_SUCCESS: 'GET_PAYMENT_METHODS_SUCCESS', GET_PAYMENT_METHODS_SUCCESS: 'GET_PAYMENT_METHODS_SUCCESS',
GET_FREE_EXTENSIONS_ERROR: 'GET_FREE_EXTENSIONS_ERROR', GET_FREE_EXTENSIONS_ERROR: 'GET_FREE_EXTENSIONS_ERROR',

View File

@ -163,8 +163,16 @@ export function setPaymentMethods( paymentMethods ) {
}; };
} }
export function setEmailPrefill( email ) {
return {
type: TYPES.SET_EMAIL_PREFILL,
emailPrefill: email,
};
}
export function* updateProfileItems( items ) { export function* updateProfileItems( items ) {
yield setIsRequesting( 'updateProfileItems', true ); yield setIsRequesting( 'updateProfileItems', true );
yield setError( 'updateProfileItems', null );
try { try {
const results = yield apiFetch( { const results = yield apiFetch( {
@ -183,7 +191,7 @@ export function* updateProfileItems( items ) {
} catch ( error ) { } catch ( error ) {
yield setError( 'updateProfileItems', error ); yield setError( 'updateProfileItems', error );
yield setIsRequesting( 'updateProfileItems', false ); yield setIsRequesting( 'updateProfileItems', false );
throw new Error(); throw error;
} }
} }

View File

@ -20,7 +20,10 @@ export const defaultState = {
skipped: null, skipped: null,
theme: null, theme: null,
wccom_connected: null, wccom_connected: null,
is_agree_marketing: null,
store_email: null,
}, },
emailPrefill: '',
paymentMethods: [], paymentMethods: [],
requesting: {}, requesting: {},
taskLists: [], taskLists: [],
@ -50,6 +53,7 @@ const onboarding = (
freeExtensions, freeExtensions,
type, type,
profileItems, profileItems,
emailPrefill,
paymentMethods, paymentMethods,
replace, replace,
error, error,
@ -69,6 +73,11 @@ const onboarding = (
? profileItems ? profileItems
: { ...state.profileItems, ...profileItems }, : { ...state.profileItems, ...profileItems },
}; };
case TYPES.SET_EMAIL_PREFILL:
return {
...state,
emailPrefill,
};
case TYPES.SET_TASKS_STATUS: case TYPES.SET_TASKS_STATUS:
return { return {
...state, ...state,

View File

@ -16,6 +16,7 @@ import {
setError, setError,
setTasksStatus, setTasksStatus,
setPaymentMethods, setPaymentMethods,
setEmailPrefill,
} from './actions'; } from './actions';
export function* getProfileItems() { export function* getProfileItems() {
@ -31,6 +32,19 @@ export function* getProfileItems() {
} }
} }
export function* getEmailPrefill() {
try {
const results = yield apiFetch( {
path: WC_ADMIN_NAMESPACE + '/onboarding/profile/get_email_prefill',
method: 'GET',
} );
yield setEmailPrefill( results.email );
} catch ( error ) {
yield setError( 'getEmailPrefill', error );
}
}
export function* getTasksStatus() { export function* getTasksStatus() {
try { try {
const results = yield apiFetch( { const results = yield apiFetch( {

View File

@ -47,6 +47,10 @@ export const isOnboardingRequesting = (
return state.requesting[ selector ] || false; return state.requesting[ selector ] || false;
}; };
export const getEmailPrefill = ( state: OnboardingState ): string => {
return state.emailPrefill || '';
};
// Types // Types
export type OnboardingSelectors = { export type OnboardingSelectors = {
getProfileItems: () => ReturnType< typeof getProfileItems >; getProfileItems: () => ReturnType< typeof getProfileItems >;
@ -64,6 +68,7 @@ export type OnboardingState = {
taskLists: TaskList[]; taskLists: TaskList[];
tasksStatus: TasksStatusState; tasksStatus: TasksStatusState;
paymentMethods: PaymentMethodsState[]; paymentMethods: PaymentMethodsState[];
emailPrefill: string;
// TODO clarify what the error record's type is // TODO clarify what the error record's type is
errors: Record< string, unknown >; errors: Record< string, unknown >;
requesting: Record< string, boolean >; requesting: Record< string, boolean >;
@ -135,6 +140,8 @@ export type ProfileItemsState = {
skipped: boolean | null; skipped: boolean | null;
theme: string | null; theme: string | null;
wccom_connected: boolean | null; wccom_connected: boolean | null;
is_agree_marketing: boolean | null;
store_email: string | null;
}; };
export type FieldLocale = { export type FieldLocale = {

View File

@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit; defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Onboarding; use Automattic\WooCommerce\Admin\Features\Onboarding;
use \Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;
/** /**
* Onboarding Profile controller. * Onboarding Profile controller.
@ -60,6 +61,18 @@ class OnboardingProfile extends \WC_REST_Data_Controller {
'schema' => array( $this, 'get_public_item_schema' ), 'schema' => array( $this, 'get_public_item_schema' ),
) )
); );
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/get_email_prefill',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_email_prefill' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
} }
/** /**
@ -139,7 +152,9 @@ class OnboardingProfile extends \WC_REST_Data_Controller {
$params = $request->get_json_params(); $params = $request->get_json_params();
$query_args = $this->prepare_objects_query( $params ); $query_args = $this->prepare_objects_query( $params );
$onboarding_data = (array) get_option( Onboarding::PROFILE_DATA_OPTION, array() ); $onboarding_data = (array) get_option( Onboarding::PROFILE_DATA_OPTION, array() );
update_option( Onboarding::PROFILE_DATA_OPTION, array_merge( $onboarding_data, $query_args ) ); $profile_data = array_merge( $onboarding_data, $query_args );
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
do_action( 'woocommerce_onboarding_profile_data_updated', $onboarding_data, $query_args );
$result = array( $result = array(
'status' => 'success', 'status' => 'success',
@ -152,6 +167,36 @@ class OnboardingProfile extends \WC_REST_Data_Controller {
return rest_ensure_response( $data ); return rest_ensure_response( $data );
} }
/**
* Returns a default email to be pre-filled in OBW. Prioritizes Jetpack if connected,
* otherwise will default to WordPress general settings.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_email_prefill( $request ) {
$result = array(
'email' => '',
);
// Attempt to get email from Jetpack.
if ( class_exists( Jetpack_Connection_Manager::class ) ) {
$jetpack_connection_manager = new Jetpack_Connection_Manager();
if ( $jetpack_connection_manager->is_active() ) {
$jetpack_user = $jetpack_connection_manager->get_connected_user_data();
$result['email'] = $jetpack_user['email'];
}
}
// Attempt to get email from WordPress general settings.
if ( empty( $result['email'] ) ) {
$result['email'] = get_option( 'admin_email' );
}
return rest_ensure_response( $result );
}
/** /**
* Prepare objects query. * Prepare objects query.
* *
@ -360,11 +405,43 @@ class OnboardingProfile extends \WC_REST_Data_Controller {
'readonly' => true, 'readonly' => true,
'validate_callback' => 'rest_validate_request_arg', 'validate_callback' => 'rest_validate_request_arg',
), ),
'is_agree_marketing' => array(
'type' => 'boolean',
'description' => __( 'Whether or not this store agreed to receiving marketing contents from WooCommerce.com.', 'woocommerce-admin' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'store_email' => array(
'type' => 'string',
'description' => __( 'Store email address.', 'woocommerce-admin' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => array( __CLASS__, 'rest_validate_marketing_email' ),
),
); );
return apply_filters( 'woocommerce_rest_onboarding_profile_properties', $properties ); return apply_filters( 'woocommerce_rest_onboarding_profile_properties', $properties );
} }
/**
* Optionally validates email if user agreed to marketing or if email is not empty.
*
* @param mixed $value Email value.
* @param WP_REST_Request $request Request object.
* @param string $param Parameter name.
* @return true|WP_Error
*/
public static function rest_validate_marketing_email( $value, $request, $param ) {
$is_agree_marketing = $request->get_param( 'is_agree_marketing' );
if (
( $is_agree_marketing || ! empty( $value ) ) &&
! is_email( $value ) ) {
return new \WP_Error( 'rest_invalid_email', __( 'Invalid email address', 'woocommerce-admin' ) );
};
return true;
}
/** /**
* Get the schema, conforming to JSON Schema. * Get the schema, conforming to JSON Schema.
* *

View File

@ -49,6 +49,7 @@ use \Automattic\WooCommerce\Admin\Notes\AddFirstProduct;
use \Automattic\WooCommerce\Admin\Notes\DrawAttention; use \Automattic\WooCommerce\Admin\Notes\DrawAttention;
use \Automattic\WooCommerce\Admin\Notes\GettingStartedInEcommerceWebinar; use \Automattic\WooCommerce\Admin\Notes\GettingStartedInEcommerceWebinar;
use \Automattic\WooCommerce\Admin\Notes\NavigationNudge; use \Automattic\WooCommerce\Admin\Notes\NavigationNudge;
use Automattic\WooCommerce\Admin\Schedulers\MailchimpScheduler;
/** /**
* Events Class. * Events Class.
@ -103,6 +104,10 @@ class Events {
if ( $this->is_merchant_email_notifications_enabled() ) { if ( $this->is_merchant_email_notifications_enabled() ) {
MerchantEmailNotifications::run(); MerchantEmailNotifications::run();
} }
if ( Features::is_enabled( 'onboarding' ) ) {
( new MailchimpScheduler() )->run();
}
} }
/** /**
@ -112,7 +117,6 @@ class Events {
NewSalesRecord::possibly_add_note(); NewSalesRecord::possibly_add_note();
MobileApp::possibly_add_note(); MobileApp::possibly_add_note();
TrackingOptIn::possibly_add_note(); TrackingOptIn::possibly_add_note();
OnboardingEmailMarketing::possibly_add_note();
OnboardingPayments::possibly_add_note(); OnboardingPayments::possibly_add_note();
PersonalizeStore::possibly_add_note(); PersonalizeStore::possibly_add_note();
WooCommercePayments::possibly_add_note(); WooCommercePayments::possibly_add_note();

View File

@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Admin\Features;
use \Automattic\WooCommerce\Admin\Loader; use \Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper; use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Admin\Schedulers\MailchimpScheduler;
/** /**
* Contains backend logic for the onboarding profile and checklist feature. * Contains backend logic for the onboarding profile and checklist feature.
@ -97,6 +98,8 @@ class Onboarding {
// Always hook into Jetpack connection even if outside of admin. // Always hook into Jetpack connection even if outside of admin.
add_action( 'jetpack_site_registered', array( $this, 'set_woocommerce_setup_jetpack_opted_in' ) ); add_action( 'jetpack_site_registered', array( $this, 'set_woocommerce_setup_jetpack_opted_in' ) );
add_action( 'woocommerce_onboarding_profile_data_updated', array( $this, 'on_profile_data_updated' ), 10, 2 );
if ( ! is_admin() ) { if ( ! is_admin() ) {
return; return;
} }
@ -977,4 +980,20 @@ class Onboarding {
} }
return $plugins; return $plugins;
} }
/**
* Delete MailchimpScheduler::SUBSCRIBED_OPTION_NAME option if profile data is being updated with a new email.
*
* @param array $existing_data Existing option data.
* @param array $updating_data Updating option data.
*/
public function on_profile_data_updated( $existing_data, $updating_data ) {
if (
isset( $existing_data['store_email'] ) &&
isset( $updating_data['store_email'] ) &&
$existing_data['store_email'] !== $updating_data['store_email']
) {
delete_option( MailchimpScheduler::SUBSCRIBED_OPTION_NAME );
}
}
} }

View File

@ -1,44 +0,0 @@
<?php
/**
* WooCommerce Admin Onboarding Email Marketing Note Provider.
*
* Adds a note to sign up to email marketing after completing the profiler.
*/
namespace Automattic\WooCommerce\Admin\Notes;
defined( 'ABSPATH' ) || exit;
/**
* Onboarding_Email_Marketing
*/
class OnboardingEmailMarketing {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-onboarding-email-marketing';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$content = __( 'We\'re here for you - get tips, product updates and inspiration straight to your email box', 'woocommerce-admin' );
$note = new Note();
$note->set_title( __( 'Sign up for tips, product updates, and inspiration', 'woocommerce-admin' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'yes-please', __( 'Yes please!', 'woocommerce-admin' ), 'https://woocommerce.us8.list-manage.com/subscribe/post?u=2c1434dc56f9506bf3c3ecd21&amp;id=13860df971&amp;SIGNUPPAGE=plugin' );
return $note;
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace Automattic\WooCommerce\Admin\Schedulers;
/**
* Class MailchimpScheduler
*
* @package Automattic\WooCommerce\Admin\Schedulers
*/
class MailchimpScheduler {
const SUBSCRIBE_ENDPOINT = 'https://woocommerce.com/wp-json/wccom/v1/subscribe';
const SUBSCRIBE_ENDPOINT_DEV = 'http://woocommerce.test/wp-json/wccom/v1/subscribe';
const SUBSCRIBED_OPTION_NAME = 'woocommerce_onboarding_subscribed_to_mailchimp';
const LOGGER_CONTEXT = 'mailchimp_scheduler';
/**
* The logger instance.
*
* @var \WC_Logger_Interface|null
*/
private $logger;
/**
* MailchimpScheduler constructor.
*
* @param \WC_Logger_Interface|null $logger Logger instance.
*/
public function __construct( \WC_Logger_Interface $logger = null ) {
if ( null === $logger ) {
$logger = wc_get_logger();
}
$this->logger = $logger;
}
/**
* Attempt to subscribe store_email to MailChimp.
*/
public function run() {
// Abort if we've already subscribed to MailChimp.
if ( 'yes' === get_option( self::SUBSCRIBED_OPTION_NAME ) ) {
return false;
}
$profile_data = get_option( 'woocommerce_onboarding_profile' );
if ( ! isset( $profile_data['is_agree_marketing'] ) || false === $profile_data['is_agree_marketing'] ) {
return false;
}
// Abort if store_email doesn't exist.
if ( ! isset( $profile_data['store_email'] ) ) {
return false;
}
$response = $this->make_request( $profile_data['store_email'] );
if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) {
$this->logger->error(
'Error getting a response from Mailchimp API.',
array( 'source' => self::LOGGER_CONTEXT )
);
return false;
} else {
$body = json_decode( $response['body'] );
if ( isset( $body->success ) && true === $body->success ) {
update_option( self::SUBSCRIBED_OPTION_NAME, 'yes' );
return true;
} else {
$this->logger->error(
// phpcs:ignore
'Incorrect response from Mailchimp API with: ' . print_r( $body, true ),
array( 'source' => self::LOGGER_CONTEXT )
);
return false;
}
}
}
/**
* Make an HTTP request to the API.
*
* @param string $store_email Email address to subscribe.
*
* @return mixed
*/
public function make_request( $store_email ) {
if ( 'development' === constant( 'WP_ENVIRONMENT_TYPE' ) ) {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT_DEV;
} else {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT;
}
return wp_remote_post(
$subscribe_endpoint,
array(
'method' => 'POST',
'body' => array(
'email' => $store_email,
),
)
);
}
}

View File

@ -6,6 +6,8 @@
*/ */
use \Automattic\WooCommerce\Admin\API\OnboardingProfile; use \Automattic\WooCommerce\Admin\API\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Schedulers\MailchimpScheduler;
/** /**
* WC Tests API Onboarding Profile * WC Tests API Onboarding Profile
@ -104,7 +106,7 @@ class WC_Tests_API_Onboarding_Profiles extends WC_REST_Unit_Test_Case {
$data = $response->get_data(); $data = $response->get_data();
$properties = $data['schema']['properties']; $properties = $data['schema']['properties'];
$this->assertCount( 13, $properties ); $this->assertCount( 15, $properties );
$this->assertArrayHasKey( 'completed', $properties ); $this->assertArrayHasKey( 'completed', $properties );
$this->assertArrayHasKey( 'skipped', $properties ); $this->assertArrayHasKey( 'skipped', $properties );
$this->assertArrayHasKey( 'industry', $properties ); $this->assertArrayHasKey( 'industry', $properties );
@ -118,6 +120,8 @@ class WC_Tests_API_Onboarding_Profiles extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'theme', $properties ); $this->assertArrayHasKey( 'theme', $properties );
$this->assertArrayHasKey( 'wccom_connected', $properties ); $this->assertArrayHasKey( 'wccom_connected', $properties );
$this->assertArrayHasKey( 'setup_client', $properties ); $this->assertArrayHasKey( 'setup_client', $properties );
$this->assertArrayHasKey( 'is_agree_marketing', $properties );
$this->assertArrayHasKey( 'store_email', $properties );
} }
/** /**
@ -179,4 +183,24 @@ class WC_Tests_API_Onboarding_Profiles extends WC_REST_Unit_Test_Case {
$this->assertTrue( is_array( $response->get_data() ) ); $this->assertTrue( is_array( $response->get_data() ) );
} }
} }
/**
* Given a profile data update API request
* When the payload has a different store_email value that the existing store_email
* Then self::SUBSCRIBED_OPTION_NAME option should be deleted.
*/
public function test_it_deletes_the_option_when_a_different_email_gets_updated() {
wp_set_current_user( $this->user );
update_option( Onboarding::PROFILE_DATA_OPTION, array( 'store_email' => 'first@test.com' ) );
update_option( MailchimpScheduler::SUBSCRIBED_OPTION_NAME, 'yes' );
$request = new WP_REST_Request( 'POST', '/wc-admin/onboarding/profile' );
$request->set_headers( array( 'content-type' => 'application/json' ) );
$request->set_body( wp_json_encode( array( 'store_email' => 'second@test.com' ) ) );
$this->server->dispatch( $request );
$this->assertFalse( get_option( MailchimpScheduler::SUBSCRIBED_OPTION_NAME, false ) );
}
} }

View File

@ -31,7 +31,8 @@
"countryandstate": "United States (US) -- California", "countryandstate": "United States (US) -- California",
"city": "San Francisco", "city": "San Francisco",
"state": "CA", "state": "CA",
"postcode": "94107" "postcode": "94107",
"email": "john.doe@example.com"
} }
}, },
"customer": { "customer": {

View File

@ -0,0 +1,166 @@
<?php
/**
* MailchimpScheduler tests
*
* @package Automattic\WooCommerce\Admin\Schedulers
*/
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Schedulers\MailchimpScheduler;
/**
* Class WC_Tests_Mailchimp_Scheduler
*/
class WC_Tests_Mailchimp_Scheduler extends WC_Unit_Test_Case {
/**
* @var MailchimpScheduler MailchimpScheduler instance to test
*/
private $instance;
/**
* @var \PHPUnit\Framework\MockObject\MockObject WC_Logger_Interface mock
*/
private $logger_mock;
/**
* Overridden setUp() method.
*/
public function setUp() {
$this->logger_mock = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
$this->instance = $this->getMockBuilder( MailchimpScheduler::class )
->setConstructorArgs( array( $this->logger_mock ) )
->setMethods( array( 'make_request' ) )
->getMock();
delete_option( MailchimpScheduler::SUBSCRIBED_OPTION_NAME );
parent::setUp();
}
/**
* When woocommerce_onboarding_subscribed_to_mailchimp value us 'yes'.
* Then it should abort.
*/
public function test_it_aborts_if_already_subscribed() {
// When.
update_option( MailchimpScheduler::SUBSCRIBED_OPTION_NAME, 'yes' );
// Then.
$this->assertFalse( $this->instance->run() );
}
/**
* When is_agree_marketing is missing or value is false.
* Then it should abort.
*/
public function test_it_aborts_if_is_agree_marketing_is_false_or_missing() {
// When.
$profile_data = array( 'store_email' => 'test@test.com' );
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
$this->assertFalse( $this->instance->run() );
$profile_data = array(
'is_agree_marketing' => false,
'store_email' => 'test@test.com',
);
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
// Then.
$this->assertFalse( $this->instance->run() );
}
/**
* When store_email is missing.
* Then it should abort.
*/
public function test_it_aborts_if_store_email_is_missing() {
// When.
$profile_data = array( 'is_agree_marketing' => true );
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
// Then.
$this->assertFalse( $this->instance->run() );
}
/**
* Given is_agree_marketing and store_email values are set.
* When the request to the API returns WP_Error.
* Then it should log an error message.
*/
public function test_it_logs_error_when_wp_error_is_returned() {
// Given.
$profile_data = array(
'is_agree_marketing' => true,
'store_email' => 'test@test.com',
);
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
// When.
$wp_error = new WP_Error();
$this->instance->method( 'make_request' )->willReturn( $wp_error );
// Then.
$this->logger_mock->expects( $this->exactly( 2 ) )
->method( 'error' )
->with( 'Error getting a response from Mailchimp API.', array( 'source' => MailchimpScheduler::LOGGER_CONTEXT ) );
$this->instance->run();
// Check for the missing 'body'.
$this->instance->method( 'make_request' )->willReturn( array() );
$this->instance->run();
}
/**
* Given is_agree_marketing and store_email values are set.
* When the request to the API returns body without 'success'.
* Then it should log an error message.
*/
public function test_it_logs_error_when_response_data_is_incorrect() {
// Given.
$profile_data = array(
'is_agree_marketing' => true,
'store_email' => 'test@test.com',
);
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
// When.
$body = wp_json_encode( array() );
$this->instance->method( 'make_request' )->willReturn( array( 'body' => $body ) );
// Then.
$this->logger_mock->expects( $this->once() )
->method( 'error' )
->with(
// phpcs:ignore
'Incorrect response from Mailchimp API with: ' . print_r( array(), true ),
array( 'source' => MailchimpScheduler::LOGGER_CONTEXT )
);
$this->instance->run();
}
/**
* Given is_agree_marketing and store_email values are set
* When the request to the API returns success: true
* Then woocommerce_onboarding_subscribed_to_mailchimp should be updated to 'yes'
*/
public function test_it_updates_option_value_when_everything_went_well() {
// Given.
$profile_data = array(
'is_agree_marketing' => true,
'store_email' => 'test@test.com',
);
update_option( Onboarding::PROFILE_DATA_OPTION, $profile_data );
// When.
$body = wp_json_encode( array( 'success' => true ) );
$this->instance->method( 'make_request' )->willReturn( array( 'body' => $body ) );
$this->instance->run();
// Then.
$this->assertEquals( 'yes', get_option( 'woocommerce_onboarding_subscribed_to_mailchimp' ) );
}
}