Adding SlotFill support for API-driven tasks components (https://github.com/woocommerce/woocommerce-admin/pull/7616)

This commit is contained in:
Joel Thiessen 2021-09-21 12:33:44 -07:00 committed by GitHub
parent b8b7c94bd1
commit f187c6763a
54 changed files with 5155 additions and 28 deletions

View File

@ -177,6 +177,9 @@ class _Layout extends Component {
{ window.wcAdminFeatures.navigation && (
<PluginArea scope="woocommerce-navigation" />
) }
{ window.wcAdminFeatures.tasks && (
<PluginArea scope="woocommerce-tasks" />
) }
</SlotFillProvider>
);
}

View File

@ -248,7 +248,7 @@
}
.woocommerce-list {
margin-top: $gap-smallest;
margin-top: $gap-large;
}
.woocommerce-list .woocommerce-list__item:first-child {

View File

@ -0,0 +1,20 @@
.woocommerce-task-marketing {
.components-card__header {
flex-direction: column;
align-items: flex-start;
display: flex !important;
h2 {
align-self: start !important;
color: $gray-900;
font-size: 20px;
margin-bottom: 0;
}
span {
margin-left: 0;
margin-top: $gap-smaller;
color: $gray-700;
}
}
}

View File

@ -0,0 +1,41 @@
.woocommerce-plugin-list__plugin {
display: flex;
padding: $gap-large;
border-top: 1px solid $gray-200;
&:first-child {
border-top: 0;
}
h4 {
margin-bottom: $gap-small;
font-weight: 600;
color: $gray-900;
}
p {
color: $gray-700;
font-weight: 400;
}
}
.woocommerce-plugin-list__plugin-logo {
margin-right: 45px;
display: flex;
align-items: center;
img {
width: 50px;
}
}
.woocommerce-plugin-list__plugin-text {
max-width: 370px;
margin-right: $gap;
}
.woocommerce-plugin-list__plugin-action {
display: flex;
align-items: center;
margin-left: auto;
}

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { recordEvent } from '@woocommerce/tracks';
import { Text } from '@woocommerce/experimental';
/**
* Internal dependencies
*/
import './Plugin.scss';
export type PluginProps = {
isActive: boolean;
isBusy?: boolean;
isDisabled?: boolean;
isInstalled: boolean;
description?: string;
installAndActivate?: ( slug: string ) => void;
imageUrl?: string;
manageUrl?: string;
name: string;
slug: string;
};
export const Plugin: React.FC< PluginProps > = ( {
description,
imageUrl,
installAndActivate = () => {},
isActive,
isBusy,
isDisabled,
isInstalled,
manageUrl,
name,
slug,
} ) => {
return (
<div className="woocommerce-plugin-list__plugin">
{ imageUrl && (
<div className="woocommerce-plugin-list__plugin-logo">
<img
src={ imageUrl }
alt={ sprintf(
/* translators: %s = name of the plugin */
__( '%s logo', 'woocommerce-admin' ),
name
) }
/>
</div>
) }
<div className="woocommerce-plugin-list__plugin-text">
<Text variant="subtitle.small" as="h4">
{ name }
</Text>
<Text variant="subtitle.small">{ description }</Text>
</div>
<div className="woocommerce-plugin-list__plugin-action">
{ isActive && manageUrl && (
<Button
disabled={ isDisabled }
isBusy={ isBusy }
isSecondary
href={ getAdminLink( manageUrl ) }
onClick={ () =>
recordEvent( 'marketing_manage', {
extension_name: slug,
} )
}
>
{ __( 'Manage', 'woocommmerce-admin' ) }
</Button>
) }
{ isInstalled && ! isActive && (
<Button
disabled={ isDisabled }
isBusy={ isBusy }
isSecondary
onClick={ () => installAndActivate( slug ) }
>
{ __( 'Activate', 'woocommmerce-admin' ) }
</Button>
) }
{ ! isInstalled && (
<Button
disabled={ isDisabled }
isBusy={ isBusy }
isSecondary
onClick={ () => installAndActivate( slug ) }
>
{ __( 'Get started', 'woocommmerce-admin' ) }
</Button>
) }
</div>
</div>
);
};

View File

@ -0,0 +1,10 @@
.woocommerce-plugin-list__title {
padding: $gap-small 30px;
background: $gray-200;
position: relative;
h3 {
font-weight: 500;
color: $black;
}
}

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { Text } from '@woocommerce/experimental';
/**
* Internal dependencies
*/
import { Plugin, PluginProps } from './Plugin';
import './PluginList.scss';
export type PluginListProps = {
currentPlugin?: string | null;
key?: string;
installAndActivate?: ( slug: string ) => void;
plugins?: PluginProps[];
title?: string;
};
export const PluginList: React.FC< PluginListProps > = ( {
currentPlugin,
installAndActivate = () => {},
plugins = [],
title,
} ) => {
return (
<div className="woocommerce-plugin-list">
{ title && (
<div className="woocommerce-plugin-list__title">
<Text variant="sectionheading" as="h3">
{ title }
</Text>
</div>
) }
{ plugins.map( ( plugin ) => {
const {
description,
imageUrl,
isActive,
isInstalled,
manageUrl,
slug,
name,
} = plugin;
return (
<Plugin
key={ slug }
description={ description }
manageUrl={ manageUrl }
name={ name }
imageUrl={ imageUrl }
installAndActivate={ installAndActivate }
isActive={ isActive }
isBusy={ currentPlugin === slug }
isDisabled={ !! currentPlugin }
isInstalled={ isInstalled }
slug={ slug }
/>
);
} ) }
</div>
);
};

View File

@ -0,0 +1,259 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Card, CardHeader, Spinner } from '@wordpress/components';
import {
ONBOARDING_STORE_NAME,
PLUGINS_STORE_NAME,
OPTIONS_STORE_NAME,
WCDataSelector,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { Text } from '@woocommerce/experimental';
import { useMemo, useState } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import './Marketing.scss';
import { createNoticesFromResponse } from '~/lib/notices';
import { PluginList, PluginListProps } from './PluginList';
import { PluginProps } from './Plugin';
const ALLOWED_PLUGIN_LISTS = [ 'reach', 'grow' ];
const EMPTY_ARRAY = [];
export type ExtensionList = {
key: string;
title: string;
plugins: Extension[];
};
export type Extension = {
description: string;
key: string;
image_url: string;
manage_url: string;
name: string;
};
export const transformExtensionToPlugin = (
extension: Extension,
activePlugins: string[],
installedPlugins: string[]
): PluginProps => {
const { description, image_url, key, manage_url, name } = extension;
const slug = key.split( ':' )[ 0 ];
return {
description,
slug,
imageUrl: image_url,
isActive: activePlugins.includes( slug ),
isInstalled: installedPlugins.includes( slug ),
manageUrl: manage_url,
name,
};
};
export const getMarketingExtensionLists = (
freeExtensions: ExtensionList[],
activePlugins: string[],
installedPlugins: string[]
): [ PluginProps[], PluginListProps[] ] => {
const installed: PluginProps[] = [];
const lists: PluginListProps[] = [];
freeExtensions.forEach( ( list ) => {
if ( ! ALLOWED_PLUGIN_LISTS.includes( list.key ) ) {
return;
}
const listPlugins: PluginProps[] = [];
list.plugins.forEach( ( extension ) => {
const plugin = transformExtensionToPlugin(
extension,
activePlugins,
installedPlugins
);
if ( plugin.isInstalled ) {
installed.push( plugin );
return;
}
listPlugins.push( plugin );
} );
if ( ! listPlugins.length ) {
return;
}
const transformedList: PluginListProps = {
...list,
plugins: listPlugins,
};
lists.push( transformedList );
} );
return [ installed, lists ];
};
export type MarketingProps = {
trackedCompletedActions: string[];
};
const Marketing: React.FC = () => {
const [ currentPlugin, setCurrentPlugin ] = useState< string | null >(
null
);
const { installAndActivatePlugins } = useDispatch( PLUGINS_STORE_NAME );
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const {
activePlugins,
freeExtensions,
installedPlugins,
isResolving,
trackedCompletedActions,
} = useSelect( ( select: WCDataSelector ) => {
const { getActivePlugins, getInstalledPlugins } = select(
PLUGINS_STORE_NAME
);
const { getFreeExtensions, hasFinishedResolution } = select(
ONBOARDING_STORE_NAME
);
const {
getOption,
hasFinishedResolution: optionFinishedResolution,
} = select( OPTIONS_STORE_NAME );
const completedActions =
getOption( 'woocommerce_task_list_tracked_completed_actions' ) ||
EMPTY_ARRAY;
return {
activePlugins: getActivePlugins(),
freeExtensions: getFreeExtensions(),
installedPlugins: getInstalledPlugins(),
isResolving: ! (
hasFinishedResolution( 'getFreeExtensions' ) &&
optionFinishedResolution( 'getOption', [
'woocommerce_task_list_tracked_completed_actions',
] )
),
trackedCompletedActions: completedActions,
};
} );
const [ installedExtensions, pluginLists ] = useMemo(
() =>
getMarketingExtensionLists(
freeExtensions,
activePlugins,
installedPlugins
),
[ installedPlugins, activePlugins, freeExtensions ]
);
const installAndActivate = ( slug: string ) => {
setCurrentPlugin( slug );
installAndActivatePlugins( [ slug ] )
.then( ( response: { errors: Record< string, string > } ) => {
recordEvent( 'tasklist_marketing_install', {
selected_extension: slug,
installed_extensions: installedExtensions.map(
( extension ) => extension.slug
),
} );
if ( ! trackedCompletedActions.includes( 'marketing' ) ) {
updateOptions( {
woocommerce_task_list_tracked_completed_actions: [
...trackedCompletedActions,
'marketing',
],
} );
}
createNoticesFromResponse( response );
setCurrentPlugin( null );
} )
.catch( ( response: { errors: Record< string, string > } ) => {
createNoticesFromResponse( response );
setCurrentPlugin( null );
} );
};
if ( isResolving ) {
return <Spinner />;
}
return (
<div className="woocommerce-task-marketing">
{ !! installedExtensions.length && (
<Card className="woocommerce-task-card">
<CardHeader>
<Text
variant="title.small"
as="h2"
className="woocommerce-task-card__title"
>
{ __(
'Installed marketing extensions',
'woocommerce-admin'
) }
</Text>
</CardHeader>
<PluginList
currentPlugin={ currentPlugin }
plugins={ installedExtensions }
/>
</Card>
) }
{ !! pluginLists.length && (
<Card className="woocommerce-task-card">
<CardHeader>
<Text
variant="title.small"
as="h2"
className="woocommerce-task-card__title"
>
{ __(
'Recommended marketing extensions',
'woocommerce-admin'
) }
</Text>
<Text as="span">
{ __(
'We recommend adding one of the following marketing tools for your store. The extension will be installed and activated for you when you click "Get started".',
'woocommerce-admin'
) }
</Text>
</CardHeader>
{ pluginLists.map( ( list ) => {
const { key, title, plugins } = list;
return (
<PluginList
currentPlugin={ currentPlugin }
installAndActivate={ installAndActivate }
key={ key }
plugins={ plugins }
title={ title }
/>
);
} ) }
</Card>
) }
</div>
);
};
registerPlugin( 'wc-admin-onboarding-task-marketing', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="marketing">
<Marketing />
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,146 @@
/**
* Internal dependencies
*/
import {
Extension,
transformExtensionToPlugin,
getMarketingExtensionLists,
} from '../';
const basicPlugins: Extension[] = [
{
key: 'basic-plugin',
name: 'Basic Plugin',
description: 'Basic plugin description',
manage_url: '#',
image_url: 'basic.jpeg',
},
];
const reachPlugins: Extension[] = [
{
key: 'reach-plugin',
name: 'Reach Plugin',
description: 'Reach plugin description',
manage_url: '#',
image_url: 'reach.jpeg',
},
];
const growPlugins: Extension[] = [
{
key: 'grow-plugin',
name: 'Grow Plugin',
description: 'Grow plugin description',
manage_url: '#',
image_url: 'grow.jpeg',
},
{
key: 'grow-plugin-two:extra',
name: 'Grow Plugin 2',
description: 'Grow plugin 2 description',
manage_url: '#',
image_url: 'grow2.jpeg',
},
];
const extensionLists = [
{
key: 'basics',
plugins: basicPlugins,
title: 'Basics',
},
{
key: 'reach',
plugins: reachPlugins,
title: 'Reach',
},
{
key: 'grow',
plugins: growPlugins,
title: 'Grow',
},
];
describe( 'transformExtensionToPlugin', () => {
test( 'should return the formatted extension', () => {
const plugin = transformExtensionToPlugin( basicPlugins[ 0 ], [], [] );
expect( plugin ).toEqual( {
description: 'Basic plugin description',
slug: 'basic-plugin',
imageUrl: 'basic.jpeg',
isActive: false,
isInstalled: false,
manageUrl: '#',
name: 'Basic Plugin',
} );
} );
test( 'should get the plugin slug when a colon exists', () => {
const plugin = transformExtensionToPlugin( growPlugins[ 1 ], [], [] );
expect( plugin.slug ).toEqual( 'grow-plugin-two' );
} );
test( 'should mark the plugin as active when in the active plugins', () => {
const plugin = transformExtensionToPlugin(
basicPlugins[ 0 ],
[ 'basic-plugin' ],
[]
);
expect( plugin.isActive ).toBeTruthy();
} );
test( 'should mark the plugin as installed when in the installed plugins', () => {
const plugin = transformExtensionToPlugin(
basicPlugins[ 0 ],
[],
[ 'basic-plugin' ]
);
expect( plugin.isInstalled ).toBeTruthy();
} );
} );
describe( 'getMarketingExtensionLists', () => {
test( 'should only return the allowed lists', () => {
const [ installed, lists ] = getMarketingExtensionLists(
extensionLists,
[],
[]
);
expect( lists.length ).toBe( 2 );
expect( lists[ 0 ].key ).toBe( 'reach' );
expect( lists[ 1 ].key ).toBe( 'grow' );
} );
test( 'should separate installed plugins', () => {
const [ installed ] = getMarketingExtensionLists(
extensionLists,
[],
[ 'grow-plugin' ]
);
expect( installed.length ).toBe( 1 );
expect( installed[ 0 ].slug ).toBe( 'grow-plugin' );
} );
test( 'should not include installed plugins in the extensions list', () => {
const [ installed, lists ] = getMarketingExtensionLists(
extensionLists,
[],
[ 'grow-plugin' ]
);
expect( lists[ 1 ].plugins.length ).toBe( 1 );
} );
test( 'should only include allowed list plugins in the installed list', () => {
const [ installed, lists ] = getMarketingExtensionLists(
extensionLists,
[],
[ 'basic-plugin' ]
);
expect( installed.length ).toBe( 0 );
} );
} );

View File

@ -0,0 +1,126 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Spinner } from '@wordpress/components';
import { updateQueryString } from '@woocommerce/navigation';
import { recordEvent } from '@woocommerce/tracks';
import { useState } from '@wordpress/element';
export const Action = ( {
hasSetup = false,
needsSetup = true,
id,
isEnabled = false,
isLoading = false,
isInstalled = false,
isRecommended = false,
hasPlugins,
manageUrl = null,
markConfigured,
onSetUp = () => {},
onSetupCallback,
setupButtonText = __( 'Set up', 'woocommerce-admin' ),
} ) => {
const [ isBusy, setIsBusy ] = useState( false );
const classes = 'woocommerce-task-payment__action';
if ( isLoading ) {
return <Spinner />;
}
const handleClick = async () => {
onSetUp( id );
if ( onSetupCallback ) {
setIsBusy( true );
await new Promise( onSetupCallback )
.then( () => {
setIsBusy( false );
} )
.catch( () => {
setIsBusy( false );
} );
return;
}
updateQueryString( {
id,
} );
};
const ManageButton = () => (
<Button
className={ classes }
isSecondary
role="button"
href={ manageUrl }
onClick={ () => recordEvent( 'tasklist_payment_manage', { id } ) }
>
{ __( 'Manage', 'woocommerce-admin' ) }
</Button>
);
const SetupButton = () => (
<Button
className={ classes }
isPrimary={ isRecommended }
isSecondary={ ! isRecommended }
isBusy={ isBusy }
disabled={ isBusy }
onClick={ () => handleClick() }
>
{ setupButtonText }
</Button>
);
if ( ! hasSetup ) {
if ( ! isEnabled ) {
return (
<Button
className={ classes }
isSecondary
onClick={ () => markConfigured( id ) }
>
{ __( 'Enable', 'woocommerce-admin' ) }
</Button>
);
}
return <ManageButton />;
}
// This isolates core gateways that include setup
if ( ! hasPlugins ) {
if ( isEnabled ) {
return <ManageButton />;
}
return <SetupButton />;
}
if ( ! needsSetup ) {
return <ManageButton />;
}
if ( isInstalled && hasPlugins ) {
return (
<div>
<Button
className={ classes }
isPrimary={ isRecommended }
isSecondary={ ! isRecommended }
isBusy={ isBusy }
disabled={ isBusy }
onClick={ () => handleClick() }
>
{ __( 'Finish setup', 'woocommerce-admin' ) }
</Button>
</div>
);
}
return <SetupButton />;
};

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { Fragment } from '@wordpress/element';
import { CardBody, CardMedia, CardDivider } from '@wordpress/components';
import { RecommendedRibbon, SetupRequired } from '@woocommerce/onboarding';
import { recordEvent } from '@woocommerce/tracks';
import { Text, useSlot } from '@woocommerce/experimental';
/**
* Internal dependencies
*/
import { Action } from '../Action';
import './List.scss';
export const Item = ( { isRecommended, markConfigured, paymentGateway } ) => {
const {
image,
content,
id,
plugins = [],
title,
loading,
enabled: isEnabled = false,
installed: isInstalled = false,
needsSetup = true,
requiredSettings,
settingsUrl: manageUrl,
is_local_partner: isLocalPartner,
} = paymentGateway;
const connectSlot = useSlot(
`woocommerce_payment_gateway_configure_${ id }`
);
const setupSlot = useSlot( `woocommerce_payment_gateway_setup_${ id }` );
const hasFills =
Boolean( connectSlot?.fills?.length ) ||
Boolean( setupSlot?.fills?.length );
const hasSetup = Boolean(
plugins.length || requiredSettings.length || hasFills
);
const showRecommendedRibbon = isRecommended && needsSetup;
const classes = classnames(
'woocommerce-task-payment',
'woocommerce-task-card',
needsSetup && 'woocommerce-task-payment-not-configured',
'woocommerce-task-payment-' + id
);
return (
<Fragment key={ id }>
<CardBody
style={ { paddingLeft: 0, marginBottom: 0 } }
className={ classes }
>
<CardMedia isBorderless>
<img src={ image } alt={ title } />
</CardMedia>
<div className="woocommerce-task-payment__description">
{ showRecommendedRibbon && (
<RecommendedRibbon isLocalPartner={ isLocalPartner } />
) }
<Text as="h3" className="woocommerce-task-payment__title">
{ title }
{ isInstalled && needsSetup && !! plugins.length && (
<SetupRequired />
) }
</Text>
<div className="woocommerce-task-payment__content">
{ content }
</div>
</div>
<div className="woocommerce-task-payment__footer">
<Action
manageUrl={ manageUrl }
id={ id }
hasSetup={ hasSetup }
needsSetup={ needsSetup }
isEnabled={ isEnabled }
isInstalled={ isInstalled }
hasPlugins={ Boolean( plugins.length ) }
isRecommended={ isRecommended }
isLoading={ loading }
markConfigured={ markConfigured }
onSetUp={ () =>
recordEvent( 'tasklist_payment_setup', {
selected: id,
} )
}
/>
</div>
</CardBody>
<CardDivider />
</Fragment>
);
};

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { Card, CardHeader } from '@wordpress/components';
/**
* Internal dependencies
*/
import { Item } from './Item';
import './List.scss';
export const List = ( {
heading,
markConfigured,
recommendation,
paymentGateways,
} ) => {
return (
<Card>
<CardHeader as="h2">{ heading }</CardHeader>
{ paymentGateways.map( ( paymentGateway ) => {
const { id } = paymentGateway;
return (
<Item
key={ id }
isRecommended={ recommendation === id }
markConfigured={ markConfigured }
paymentGateway={ paymentGateway }
/>
);
} ) }
</Card>
);
};

View File

@ -0,0 +1,102 @@
.woocommerce-task-payment {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
overflow: hidden;
.components-card__media {
width: 170px;
flex-shrink: 0;
img,
svg,
.is-placeholder {
margin: auto;
max-width: 100px;
display: block;
}
.is-placeholder {
height: 36px;
}
}
.woocommerce-task-payment__footer {
.is-placeholder {
width: 70px;
height: 36px;
}
}
> .components-form-toggle {
min-width: 52px;
}
.woocommerce-task-payment__title {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 500;
color: $studio-gray-80;
margin-top: 0;
margin-bottom: $gap-smaller;
}
.woocommerce-task-payment__content {
font-size: 14px;
color: $studio-gray-60;
margin: 0;
margin-right: $gap-larger;
.text-style-strong {
font-weight: bold;
}
p {
font-size: 12px;
}
}
.woocommerce-task-payment__description {
flex: 1;
}
@include breakpoint( '<600px' ) {
flex-wrap: wrap;
.woocommerce-task-payment__content {
margin: 0;
}
.components-card__media {
> svg,
> img {
margin: 0 0 0 $gap-large;
}
order: 1;
flex-basis: 50%;
}
.woocommerce-task-payment__description {
order: 3;
padding: $gap-large 0 0 $gap-large;
}
.woocommerce-task-payment__footer {
flex-basis: 50%;
align-self: flex-start;
order: 2;
text-align: right;
}
}
}
.woocommerce-payment-gateway-suggestions-list-placeholder {
.is-placeholder {
@include placeholder();
display: inline-block;
max-width: 240px;
width: 80%;
}
}

View File

@ -0,0 +1,66 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { Fragment } from '@wordpress/element';
import {
Card,
CardHeader,
CardBody,
CardMedia,
CardDivider,
} from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
/**
* Internal dependencies
*/
import './List.scss';
const PlaceholderItem = () => {
const classes = classnames(
'woocommerce-task-payment',
'woocommerce-task-card'
);
return (
<Fragment>
<CardBody
style={ { paddingLeft: 0, marginBottom: 0 } }
className={ classes }
>
<CardMedia isBorderless>
<span className="is-placeholder" />
</CardMedia>
<div className="woocommerce-task-payment__description">
<Text as="h3" className="woocommerce-task-payment__title">
<span className="is-placeholder" />
</Text>
<div className="woocommerce-task-payment__content">
<span className="is-placeholder" />
</div>
</div>
<div className="woocommerce-task-payment__footer">
<span className="is-placeholder" />
</div>
</CardBody>
<CardDivider />
</Fragment>
);
};
export const Placeholder = () => {
const classes =
'is-loading woocommerce-payment-gateway-suggestions-list-placeholder';
return (
<Card aria-hidden="true" className={ classes }>
<CardHeader as="h2">
<span className="is-placeholder" />
</CardHeader>
<PlaceholderItem />
<PlaceholderItem />
<PlaceholderItem />
</Card>
);
};

View File

@ -0,0 +1,2 @@
export { List } from './List';
export { Placeholder } from './Placeholder';

View File

@ -0,0 +1,209 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import { List } from '../List';
jest.mock( '@woocommerce/tracks', () => ( {
recordEvent: jest.fn(),
} ) );
const mockGateway = {
id: 'mock-gateway',
title: 'Mock Gateway',
plugins: [],
postInstallScripts: [],
requiredSettings: [],
installed: false,
needsSetup: false,
enabled: false,
};
const defaultProps = {
heading: 'Test heading',
markConfigured: jest.fn(),
recommendation: 'testId',
paymentGateways: [ mockGateway ],
};
describe( 'PaymentGatewaySuggestions > List', () => {
it( 'should display correct heading', () => {
const { queryByText } = render( <List { ...defaultProps } /> );
expect( queryByText( defaultProps.heading ) ).toBeInTheDocument();
} );
it( 'should display gateway title', () => {
const { queryByText } = render( <List { ...defaultProps } /> );
expect( queryByText( mockGateway.title ) ).toBeInTheDocument();
} );
it( 'should display the "Enable" button when setup is NOT required', () => {
const { queryByRole } = render( <List { ...defaultProps } /> );
expect( queryByRole( 'button' ) ).toHaveTextContent( 'Enable' );
} );
it( 'should display the "Set up" button when setup is required', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
needsSetup: true,
plugins: [ 'test-plugins' ],
},
],
};
const { queryByRole } = render( <List { ...props } /> );
expect( queryByRole( 'button' ) ).toHaveTextContent( 'Set up' );
} );
it( 'should display the SetupRequired component when appropriate', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
needsSetup: true,
installed: true,
plugins: [ 'test-plugin' ],
},
],
};
const { queryByText } = render( <List { ...props } /> );
expect( queryByText( 'Setup required' ) ).toBeInTheDocument();
} );
it( 'should not display the SetupRequired component when not appropriate', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
needsSetup: true,
installed: false,
plugins: [ 'test-plugin' ],
},
],
};
const { queryByText } = render( <List { ...props } /> );
expect( queryByText( 'Setup required' ) ).not.toBeInTheDocument();
} );
it( 'should display the Recommended ribbon when appropriate', () => {
const props = {
...defaultProps,
recommendation: 'mock-gateway',
paymentGateways: [
{
...mockGateway,
id: 'mock-gateway',
needsSetup: true,
},
],
};
const { queryByText } = render( <List { ...props } /> );
expect( queryByText( 'Recommended' ) ).toBeInTheDocument();
} );
it( 'should not display the Recommended ribbon when gateway id does not match', () => {
const props = {
...defaultProps,
recommendation: 'mock-gateway',
paymentGateways: [
{
...mockGateway,
id: 'mock-gateway-other',
needsSetup: true,
},
],
};
const { queryByText } = render( <List { ...props } /> );
expect( queryByText( 'Recommended' ) ).not.toBeInTheDocument();
} );
it( 'should display Manage button if not enabled and does have setup', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
enabled: true,
},
],
};
const { queryByRole } = render( <List { ...props } /> );
expect( queryByRole( 'button' ) ).toHaveTextContent( 'Manage' );
} );
it( 'should display Manage button for core plugins that are enabled', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
requiredSettings: [ 'just', 'kidding' ],
enabled: true,
plugins: [],
},
],
};
const { queryByRole } = render( <List { ...props } /> );
expect( queryByRole( 'button' ) ).toHaveTextContent( 'Manage' );
} );
it( 'should display Manage button if it does have plugins and does not need setup', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
plugins: [ 'nope' ],
needsSetup: false,
},
],
};
const { queryByRole } = render( <List { ...props } /> );
expect( queryByRole( 'button' ) ).toHaveTextContent( 'Manage' );
} );
it( 'should display Finish Setup button when installed but not setup', () => {
const props = {
...defaultProps,
paymentGateways: [
{
...mockGateway,
plugins: [ 'nope' ],
needsSetup: true,
installed: true,
},
],
};
const { queryByRole } = render( <List { ...props } /> );
expect( queryByRole( 'button' ) ).toHaveTextContent( 'Finish setup' );
} );
} );

View File

@ -0,0 +1,166 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { PAYMENT_GATEWAYS_STORE_NAME } from '@woocommerce/data';
import { DynamicForm } from '@woocommerce/components';
import { WooPaymentGatewayConfigure } from '@woocommerce/onboarding';
import { useSlot } from '@woocommerce/experimental';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import sanitizeHTML from '~/lib/sanitize-html';
export const validateFields = ( values, fields ) => {
const errors = {};
const getField = ( fieldId ) =>
fields.find( ( field ) => field.id === fieldId );
for ( const [ fieldKey, value ] of Object.entries( values ) ) {
const field = getField( fieldKey );
// Matches any word that is capitalized aside from abrevitions like ID.
const label = field.label.replace( /([A-Z][a-z]+)/g, ( val ) =>
val.toLowerCase()
);
if ( ! ( value || field.type === 'checkbox' ) ) {
errors[ fieldKey ] = `Please enter your ${ label }`;
}
}
return errors;
};
export const Configure = ( { markConfigured, paymentGateway } ) => {
const {
id,
connectionUrl,
setupHelpText,
settingsUrl,
title,
requiredSettings: fields,
} = paymentGateway;
const { createNotice } = useDispatch( 'core/notices' );
const { updatePaymentGateway } = useDispatch( PAYMENT_GATEWAYS_STORE_NAME );
const slot = useSlot( `woocommerce_payment_gateway_configure_${ id }` );
const hasFills = Boolean( slot?.fills?.length );
const { isUpdating } = useSelect( ( select ) => {
const { isPaymentGatewayUpdating } = select(
PAYMENT_GATEWAYS_STORE_NAME
);
return {
isUpdating: isPaymentGatewayUpdating(),
};
} );
const handleSubmit = ( values ) => {
updatePaymentGateway( id, {
enabled: true,
settings: values,
} )
.then( ( result ) => {
if ( result && result.id === id ) {
markConfigured( id );
createNotice(
'success',
sprintf(
/* translators: %s = title of the payment gateway */
__(
'%s configured successfully',
'woocommerce-admin'
),
title
)
);
}
} )
.catch( () => {
createNotice(
'error',
__(
'There was a problem saving your payment settings',
'woocommerce-admin'
)
);
} );
};
const helpText = setupHelpText && (
<p dangerouslySetInnerHTML={ sanitizeHTML( setupHelpText ) } />
);
const defaultForm = (
<DynamicForm
fields={ fields }
isBusy={ isUpdating }
onSubmit={ handleSubmit }
submitLabel={ __( 'Proceed', 'woocommerce-admin' ) }
validate={ ( values ) => validateFields( values, fields ) }
/>
);
if ( hasFills ) {
return (
<WooPaymentGatewayConfigure.Slot
fillProps={ {
defaultForm,
defaultSubmit: handleSubmit,
defaultFields: fields,
markConfigured: () => markConfigured( id ),
paymentGateway,
} }
id={ id }
/>
);
}
if ( connectionUrl ) {
return (
<>
{ helpText }
<Button
isPrimary
onClick={ () =>
recordEvent( 'tasklist_payment_connect_start', {
payment_method: id,
} )
}
href={ connectionUrl }
>
{ __( 'Connect', 'woocommerce-admin' ) }
</Button>
</>
);
}
if ( fields.length ) {
return (
<>
{ helpText }
{ defaultForm }
</>
);
}
return (
<>
{ helpText || (
<p>
{ __(
"You can manage this payment gateway's settings by clicking the button below",
'woocommerce-admin'
) }
</p>
) }
<Button isPrimary href={ settingsUrl }>
{ __( 'Set up', 'woocommerce-admin' ) }
</Button>
</>
);
};

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { Card, CardBody } from '@wordpress/components';
import { Stepper } from '@woocommerce/components';
export const Placeholder = () => {
const classes = classnames(
'is-loading',
'woocommerce-task-payment-method',
'woocommerce-task-card'
);
return (
<Card aria-hidden="true" className={ classes }>
<CardBody>
<Stepper
isVertical
currentStep={ 'none' }
steps={ [
{
key: 'first',
label: '',
},
{
key: 'second',
label: '',
},
] }
/>
</CardBody>
</Card>
);
};

View File

@ -0,0 +1,173 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Card, CardBody } from '@wordpress/components';
import { enqueueScript } from '@woocommerce/wc-admin-settings';
import {
OPTIONS_STORE_NAME,
PAYMENT_GATEWAYS_STORE_NAME,
PLUGINS_STORE_NAME,
} from '@woocommerce/data';
import { Plugins, Stepper } from '@woocommerce/components';
import { WooPaymentGatewaySetup } from '@woocommerce/onboarding';
import { recordEvent } from '@woocommerce/tracks';
import { useEffect, useState, useMemo } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { useSlot } from '@woocommerce/experimental';
/**
* Internal dependencies
*/
import { createNoticesFromResponse } from '~/lib/notices';
import { Configure } from './Configure';
import './Setup.scss';
export const Setup = ( { markConfigured, paymentGateway } ) => {
const {
id,
plugins = [],
title,
postInstallScripts,
installed: gatewayInstalled,
} = paymentGateway;
const slot = useSlot( `woocommerce_payment_gateway_setup_${ id }` );
const hasFills = Boolean( slot?.fills?.length );
const [ isPluginLoaded, setIsPluginLoaded ] = useState( false );
useEffect( () => {
recordEvent( 'payments_task_stepper_view', {
payment_method: id,
} );
}, [] );
const { invalidateResolutionForStoreSelector } = useDispatch(
PAYMENT_GATEWAYS_STORE_NAME
);
const {
isOptionUpdating,
isPaymentGatewayResolving,
needsPluginInstall,
} = useSelect( ( select ) => {
const { isOptionsUpdating } = select( OPTIONS_STORE_NAME );
const { isResolving } = select( PAYMENT_GATEWAYS_STORE_NAME );
const activePlugins = select( PLUGINS_STORE_NAME ).getActivePlugins();
const pluginsToInstall = plugins.filter(
( m ) => ! activePlugins.includes( m )
);
return {
isOptionUpdating: isOptionsUpdating(),
isPaymentGatewayResolving: isResolving( 'getPaymentGateways' ),
needsPluginInstall: !! pluginsToInstall.length,
};
} );
useEffect( () => {
if ( needsPluginInstall ) {
return;
}
if ( postInstallScripts && postInstallScripts.length ) {
const scriptPromises = postInstallScripts.map( ( script ) =>
enqueueScript( script )
);
Promise.all( scriptPromises ).then( () => {
setIsPluginLoaded( true );
} );
return;
}
setIsPluginLoaded( true );
}, [ postInstallScripts, needsPluginInstall ] );
const installStep = useMemo( () => {
return plugins && plugins.length
? {
key: 'install',
label: sprintf(
/* translators: %s = title of the payment gateway to install */
__( 'Install %s', 'woocommerce-admin' ),
title
),
content: (
<Plugins
onComplete={ ( installedPlugins, response ) => {
createNoticesFromResponse( response );
invalidateResolutionForStoreSelector(
'getPaymentGateways'
);
recordEvent(
'tasklist_payment_install_method',
{
plugins,
}
);
} }
onError={ ( errors, response ) =>
createNoticesFromResponse( response )
}
autoInstall
pluginSlugs={ plugins }
/>
),
}
: null;
}, [] );
const configureStep = useMemo(
() => ( {
key: 'configure',
label: sprintf(
__( 'Configure your %(title)s account', 'woocommerce-admin' ),
{
title,
}
),
content: gatewayInstalled ? (
<Configure
markConfigured={ markConfigured }
paymentGateway={ paymentGateway }
/>
) : null,
} ),
[ gatewayInstalled ]
);
const stepperPending =
needsPluginInstall ||
isOptionUpdating ||
isPaymentGatewayResolving ||
! isPluginLoaded;
const defaultStepper = (
<Stepper
isVertical
isPending={ stepperPending }
currentStep={ needsPluginInstall ? 'install' : 'configure' }
steps={ [ installStep, configureStep ].filter( Boolean ) }
/>
);
return (
<Card className="woocommerce-task-payment-method woocommerce-task-card">
<CardBody>
{ hasFills ? (
<WooPaymentGatewaySetup.Slot
fillProps={ {
defaultStepper,
defaultInstallStep: installStep,
defaultConfigureStep: configureStep,
markConfigured: () => markConfigured( id ),
paymentGateway,
} }
id={ id }
/>
) : (
defaultStepper
) }
</CardBody>
</Card>
);
};

View File

@ -0,0 +1,19 @@
.woocommerce-task-payment-method.is-loading {
.woocommerce-stepper__step-label {
@include placeholder();
display: inline-block;
width: 60%;
}
.woocommerce-stepper__step-icon {
@include placeholder();
}
.woocommerce-stepper__step:nth-child(1) .woocommerce-stepper__step-label {
width: 30%;
}
.woocommerce-stepper__step-number {
display: none;
}
}

View File

@ -0,0 +1,2 @@
export { Setup } from './Setup';
export { Placeholder } from './Placeholder';

View File

@ -0,0 +1,111 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { Configure, validateFields } from '../Configure';
const mockGateway = {
id: 'mock-gateway',
title: 'Mock Gateway',
connectionUrl: 'http://mockgateway.com/connect',
setupHelpText: 'Help text',
settingsUrl:
'https://example.com/wp-admin/admin.php?page=wc-settings&tab=checkout&section=mock-gateway',
requiredSettings: [
{
id: 'api_key',
label: 'API key',
type: 'text',
default: '',
},
{
id: 'api_secret',
label: 'API secret',
type: 'text',
default: '',
},
],
};
const defaultProps = {
markConfigured: () => {},
recordConnectStartEvent: () => {},
paymentGateway: mockGateway,
};
describe( 'Configure', () => {
it( 'should show help text', () => {
const { queryByText } = render( <Configure { ...defaultProps } /> );
expect( queryByText( 'Help text' ) ).toBeInTheDocument();
} );
it( 'should render a button with the connection URL', () => {
const { container } = render( <Configure { ...defaultProps } /> );
const button = container.querySelector( 'a' );
expect( button.textContent ).toBe( 'Connect' );
expect( button.href ).toBe( mockGateway.connectionUrl );
} );
it( 'should render fields when no connection URL exists', () => {
const props = {
...defaultProps,
paymentGateway: {
...mockGateway,
connectionUrl: null,
},
};
const { container } = render( <Configure { ...props } /> );
const inputs = container.querySelectorAll( 'input' );
expect( inputs.length ).toBe( 2 );
expect( inputs[ 0 ].placeholder ).toBe( 'API key' );
expect( inputs[ 1 ].placeholder ).toBe( 'API secret' );
} );
it( 'should render the set up button when no connection URL or fields exist', () => {
const props = {
...defaultProps,
paymentGateway: {
...mockGateway,
connectionUrl: null,
requiredSettings: [],
},
};
const { container } = render( <Configure { ...props } /> );
const button = container.querySelector( 'a' );
expect( button.textContent ).toBe( 'Set up' );
expect( button.href ).toBe( mockGateway.settingsUrl );
} );
} );
describe( 'validateFields', () => {
it( 'should return an empty object when no errors exist', () => {
const values = {
api_key: '123',
api_secret: '123',
};
const errors = validateFields( values, mockGateway.requiredSettings );
expect( errors ).toMatchObject( {} );
} );
it( 'should return the errors using field labels when errors exist', () => {
const values = {
api_key: '123',
api_secret: null,
};
const errors = validateFields( values, mockGateway.requiredSettings );
expect( errors ).toMatchObject( {
api_secret: 'Please enter your API secret',
} );
} );
} );

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { act, render } from '@testing-library/react';
import { enqueueScript } from '@woocommerce/wc-admin-settings';
/**
* Internal dependencies
*/
import { Setup } from '../';
jest.mock( '@woocommerce/components', () => {
const originalModule = jest.requireActual( '@woocommerce/components' );
return {
DynamicForm: () => <div />,
Plugins: () => <div />,
Stepper: originalModule.Stepper,
};
} );
jest.mock( '@woocommerce/wc-admin-settings' );
const mockGateway = {
id: 'mock-gateway',
title: 'Mock Gateway',
plugins: [],
postInstallScripts: [],
installed: false,
};
const defaultProps = {
markConfigured: () => {},
recordConnectStartEvent: () => {},
paymentGateway: mockGateway,
};
describe( 'Setup', () => {
it( 'should show a configure step', () => {
const { queryByText } = render( <Setup { ...defaultProps } /> );
expect(
queryByText( 'Configure your Mock Gateway account' )
).toBeInTheDocument();
} );
it( 'should not show install step when no plugins are needed', () => {
const { queryByText } = render( <Setup { ...defaultProps } /> );
expect( queryByText( 'Install' ) ).not.toBeInTheDocument();
} );
it( 'should show install step when plugins are needed', () => {
const props = {
...defaultProps,
paymentGateway: { ...mockGateway, plugins: [ 'mock-plugin' ] },
};
const { queryByText } = render( <Setup { ...props } /> );
expect( queryByText( 'Install Mock Gateway' ) ).toBeInTheDocument();
} );
it( 'should enqueue post install scripts when plugin installation completes', async () => {
const props = {
...defaultProps,
paymentGateway: {
...mockGateway,
postInstallScripts: [ 'mock-post-install-script' ],
},
};
await act( async () => {
render( <Setup { ...props } /> );
} );
expect( enqueueScript ).toHaveBeenCalledTimes( 1 );
expect( enqueueScript ).toHaveBeenCalledWith(
'mock-post-install-script'
);
} );
} );

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import interpolateComponents from 'interpolate-components';
import { Link, Pill } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { Text } from '@woocommerce/experimental';
import {
WCPayCard,
WCPayCardHeader,
WCPayCardFooter,
WCPayCardBody,
SetupRequired,
} from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import { Action } from '../Action';
const TosPrompt = () =>
interpolateComponents( {
mixedString: __(
'Upon clicking "Get started", you agree to the {{link}}Terms of service{{/link}}. Next well ask you to share a few details about your business to create your account.',
'woocommerce-admin'
),
components: {
link: (
<Link
href={ 'https://wordpress.com/tos/' }
target="_blank"
type="external"
/>
),
},
} );
export const Suggestion = ( { paymentGateway, onSetupCallback = null } ) => {
const {
description,
id,
needsSetup,
installed,
enabled: isEnabled,
installed: isInstalled,
} = paymentGateway;
return (
<WCPayCard>
<WCPayCardHeader>
{ installed && needsSetup ? (
<SetupRequired />
) : (
<Pill>{ __( 'Recommended', 'woocommerce-admin' ) }</Pill>
) }
</WCPayCardHeader>
<WCPayCardBody
description={ description }
onLinkClick={ () => {
recordEvent( 'tasklist_payment_learn_more' );
} }
/>
<WCPayCardFooter>
<>
<Text lineHeight="1.5em">
<TosPrompt />
</Text>
<Action
id={ id }
hasSetup={ true }
needsSetup={ needsSetup }
isEnabled={ isEnabled }
isRecommended={ true }
isInstalled={ isInstalled }
hasPlugins={ true }
setupButtonText={ __(
'Get started',
'woocommerce-admin'
) }
onSetupCallback={ onSetupCallback }
/>
</>
</WCPayCardFooter>
</WCPayCard>
);
};

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { getQuery, updateQueryString } from '@woocommerce/navigation';
import interpolateComponents from 'interpolate-components';
import { Link } from '@woocommerce/components';
/**
* Internal dependencies
*/
import Modal from '~/profile-wizard/steps/usage-modal';
export const UsageModal = () => {
const query = getQuery();
const shouldDisplayModal = query[ 'wcpay-connection-success' ] === '1';
const [ isOpen, setIsOpen ] = useState( shouldDisplayModal );
if ( ! isOpen ) {
return null;
}
const closeModal = () => {
setIsOpen( false );
updateQueryString( { 'wcpay-connection-success': undefined } );
};
const title = __(
'Help us build a better WooCommerce Payments experience',
'woocommerce-admin'
);
const trackingMessage = interpolateComponents( {
mixedString: __(
'By agreeing to share non-sensitive {{link}}usage data{{/link}}, youll help us improve features and optimize the WooCommerce Payments experience. You can opt out at any time.',
'woocommerce-admin'
),
components: {
link: (
<Link
href="https://woocommerce.com/usage-tracking?utm_medium=product"
target="_blank"
type="external"
/>
),
},
} );
return (
<Modal
isDismissible={ false }
title={ title }
message={ trackingMessage }
acceptActionText={ __( 'I agree', 'woocommerce-admin' ) }
dismissActionText={ __( 'No thanks', 'woocommerce-admin' ) }
onContinue={ closeModal }
onClose={ closeModal }
/>
);
};
export default UsageModal;

View File

@ -0,0 +1,3 @@
export * from './utils';
export { Suggestion as WCPaySuggestion } from './Suggestion';
export { UsageModal as WCPayUsageModal } from './UsageModal';

View File

@ -0,0 +1,76 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { WC_ADMIN_NAMESPACE } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { createNoticesFromResponse } from '~/lib/notices';
export function installActivateAndConnectWcpay(
reject,
createNotice,
installAndActivatePlugins
) {
const errorMessage = __(
'There was an error connecting to WooCommerce Payments. Please try again or connect later in store settings.',
'woocommerce-admin'
);
const connect = () => {
apiFetch( {
path: WC_ADMIN_NAMESPACE + '/plugins/connect-wcpay',
method: 'POST',
} )
.then( ( response ) => {
window.location = response.connectUrl;
} )
.catch( () => {
createNotice( 'error', errorMessage );
reject();
} );
};
installAndActivatePlugins( [ 'woocommerce-payments' ] )
.then( () => {
recordEvent( 'woocommerce_payments_install', {
context: 'tasklist',
} );
connect();
} )
.catch( ( error ) => {
createNoticesFromResponse( error );
reject();
} );
}
export function isWCPaySupported( countryCode ) {
const supportedCountries = [
'US',
'PR',
'AU',
'CA',
'DE',
'ES',
'FR',
'GB',
'IE',
'IT',
'NZ',
'AT',
'BE',
'NL',
'PL',
'PT',
'CH',
'HK',
'SG',
];
return supportedCountries.includes( countryCode );
}

View File

@ -0,0 +1,248 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { getHistory, getNewPath } from '@woocommerce/navigation';
import {
OPTIONS_STORE_NAME,
ONBOARDING_STORE_NAME,
PAYMENT_GATEWAYS_STORE_NAME,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useMemo, useCallback, useEffect } from '@wordpress/element';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import { List, Placeholder as ListPlaceholder } from './components/List';
import { Setup, Placeholder as SetupPlaceholder } from './components/Setup';
import { WCPaySuggestion } from './components/WCPay';
import './plugins/Bacs';
export const PaymentGatewaySuggestions = ( { query } ) => {
const { invalidateResolutionForStoreSelector } = useDispatch(
ONBOARDING_STORE_NAME
);
const { updatePaymentGateway } = useDispatch( PAYMENT_GATEWAYS_STORE_NAME );
const {
getPaymentGateway,
paymentGatewaySuggestions,
installedPaymentGateways,
isResolving,
} = useSelect( ( select ) => {
return {
getPaymentGateway: select( PAYMENT_GATEWAYS_STORE_NAME )
.getPaymentGateway,
getOption: select( OPTIONS_STORE_NAME ).getOption,
installedPaymentGateways: select(
PAYMENT_GATEWAYS_STORE_NAME
).getPaymentGateways(),
isResolving: select( ONBOARDING_STORE_NAME ).isResolving(
'getPaymentGatewaySuggestions'
),
paymentGatewaySuggestions: select(
ONBOARDING_STORE_NAME
).getPaymentGatewaySuggestions(),
};
}, [] );
const getEnrichedPaymentGateways = () => {
const mappedPaymentGateways = installedPaymentGateways.reduce(
( map, gateway ) => {
map[ gateway.id ] = gateway;
return map;
},
{}
);
return paymentGatewaySuggestions.reduce( ( map, suggestion ) => {
const { id } = suggestion;
const installedGateway = mappedPaymentGateways[ suggestion.id ]
? mappedPaymentGateways[ id ]
: {};
const enrichedSuggestion = {
installed: !! mappedPaymentGateways[ id ],
postInstallScripts: installedGateway.post_install_scripts,
enabled: installedGateway.enabled || false,
needsSetup: installedGateway.needs_setup,
settingsUrl: installedGateway.settings_url,
connectionUrl: installedGateway.connection_url,
setupHelpText: installedGateway.setup_help_text,
title: installedGateway.title,
requiredSettings: installedGateway.required_settings_keys
? installedGateway.required_settings_keys
.map(
( settingKey ) =>
installedGateway.settings[ settingKey ]
)
.filter( Boolean )
: [],
...suggestion,
};
map.set( id, enrichedSuggestion );
return map;
}, new Map() );
};
const paymentGateways = useMemo( getEnrichedPaymentGateways, [
installedPaymentGateways,
paymentGatewaySuggestions,
] );
useEffect( () => {
if ( paymentGateways.size ) {
recordEvent( 'tasklist_payments_options', {
options: Array.from( paymentGateways.values() ).map(
( gateway ) => gateway.id
),
} );
}
}, [ paymentGateways ] );
const enablePaymentGateway = ( id ) => {
if ( ! id ) {
return;
}
const gateway = getPaymentGateway( id );
if ( ! gateway || gateway.enabled ) {
return;
}
updatePaymentGateway( id, {
enabled: true,
} ).then( () => {
invalidateResolutionForStoreSelector( 'getTasksStatus' );
} );
};
const markConfigured = useCallback(
async ( id, queryParams = {} ) => {
if ( ! paymentGateways.get( id ) ) {
throw `Payment gateway ${ id } not found in available gateways list`;
}
enablePaymentGateway( id );
recordEvent( 'tasklist_payment_connect_method', {
payment_method: id,
} );
getHistory().push( getNewPath( { ...queryParams }, '/', {} ) );
},
[ paymentGateways ]
);
const recommendation = useMemo(
() =>
Array.from( paymentGateways.values() )
.filter( ( gateway ) => gateway.recommendation_priority )
.sort(
( a, b ) =>
a.recommendation_priority - b.recommendation_priority
)
.map( ( gateway ) => gateway.id )
.shift(),
[ paymentGateways ]
);
const currentGateway = useMemo( () => {
if ( ! query.id || isResolving || ! paymentGateways.size ) {
return null;
}
const gateway = paymentGateways.get( query.id );
if ( ! gateway ) {
throw `Current gateway ${ query.id } not found in available gateways list`;
}
return gateway;
}, [ isResolving, query, paymentGateways ] );
const [ wcPayGateway, enabledGateways, additionalGateways ] = useMemo(
() =>
Array.from( paymentGateways.values() ).reduce(
( all, gateway ) => {
const [ wcPay, enabled, additional ] = all;
// WCPay is handled separately when not installed and configured
if (
gateway.id === 'woocommerce_payments' &&
! ( gateway.installed && ! gateway.needsSetup )
) {
wcPay.push( gateway );
} else if ( gateway.enabled ) {
enabled.push( gateway );
} else {
additional.push( gateway );
}
return all;
},
[ [], [], [] ]
),
[ paymentGateways ]
);
if ( query.id && ! currentGateway ) {
return <SetupPlaceholder />;
}
if ( currentGateway ) {
return (
<Setup
paymentGateway={ currentGateway }
markConfigured={ markConfigured }
/>
);
}
return (
<div className="woocommerce-task-payments">
{ ! paymentGateways.size && <ListPlaceholder /> }
{ !! wcPayGateway.length && (
<WCPaySuggestion paymentGateway={ wcPayGateway[ 0 ] } />
) }
{ !! enabledGateways.length && (
<List
heading={ __(
'Enabled payment gateways',
'woocommerce-admin'
) }
recommendation={ recommendation }
paymentGateways={ enabledGateways }
/>
) }
{ !! additionalGateways.length && (
<List
heading={ __(
'Additional payment gateways',
'woocommerce-admin'
) }
recommendation={ recommendation }
paymentGateways={ additionalGateways }
markConfigured={ markConfigured }
/>
) }
</div>
);
};
registerPlugin( 'wc-admin-onboarding-task-payments', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="payments">
{ ( { query } ) => <PaymentGatewaySuggestions query={ query } /> }
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,179 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { Form, H, TextControl } from '@woocommerce/components';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { registerPlugin } from '@wordpress/plugins';
import { useDispatch, useSelect } from '@wordpress/data';
import { WooPaymentGatewaySetup } from '@woocommerce/onboarding';
const initialFormValues = {
account_name: '',
account_number: '',
bank_name: '',
sort_code: '',
iban: '',
bic: '',
};
const BacsPaymentGatewaySetup = () => {
const isUpdating = useSelect( ( select ) => {
return select( OPTIONS_STORE_NAME ).isOptionsUpdating();
} );
const { createNotice } = useDispatch( 'core/notices' );
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const validate = ( values ) => {
const errors = {};
if ( ! values.account_number && ! values.iban ) {
errors.account_number = errors.iban = __(
'Please enter an account number or IBAN',
'woocommerce-admin'
);
}
return errors;
};
const updateSettings = async ( values, markConfigured ) => {
const update = await updateOptions( {
woocommerce_bacs_settings: {
enabled: 'yes',
},
woocommerce_bacs_accounts: [ values ],
} );
if ( update.success ) {
markConfigured();
createNotice(
'success',
__(
'Direct bank transfer details added successfully',
'woocommerce-admin'
)
);
return;
}
createNotice(
'error',
__(
'There was a problem saving your payment settings',
'woocommerce-admin'
)
);
};
return (
<>
<WooPaymentGatewaySetup id="bacs">
{ ( { markConfigured } ) => {
return (
<Form
initialValues={ initialFormValues }
onSubmit={ ( values ) =>
updateSettings( values, markConfigured )
}
validate={ validate }
>
{ ( { getInputProps, handleSubmit } ) => {
return (
<>
<H>
{ __(
'Add your bank details',
'woocommerce-admin'
) }
</H>
<p>
{ __(
'These details are required to receive payments via bank transfer',
'woocommerce-admin'
) }
</p>
<div className="woocommerce-task-payment-method__fields">
<TextControl
label={ __(
'Account name',
'woocommerce-admin'
) }
required
{ ...getInputProps(
'account_name'
) }
/>
<TextControl
label={ __(
'Account number',
'woocommerce-admin'
) }
required
{ ...getInputProps(
'account_number'
) }
/>
<TextControl
label={ __(
'Bank name',
'woocommerce-admin'
) }
required
{ ...getInputProps(
'bank_name'
) }
/>
<TextControl
label={ __(
'Sort code',
'woocommerce-admin'
) }
required
{ ...getInputProps(
'sort_code'
) }
/>
<TextControl
label={ __(
'IBAN',
'woocommerce-admin'
) }
required
{ ...getInputProps( 'iban' ) }
/>
<TextControl
label={ __(
'BIC / Swift',
'woocommerce-admin'
) }
required
{ ...getInputProps( 'bic' ) }
/>
</div>
<Button
isPrimary
isBusy={ isUpdating }
onClick={ handleSubmit }
>
{ __(
'Save',
'woocommerce-admin'
) }
</Button>
</>
);
} }
</Form>
);
} }
</WooPaymentGatewaySetup>
</>
);
};
registerPlugin( 'wc-admin-payment-gateway-setup-bacs', {
render: BacsPaymentGatewaySetup,
scope: 'woocommerce-tasks',
} );

View File

@ -0,0 +1,455 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { Button, Card, CardBody } from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { filter } from 'lodash';
import { withDispatch, withSelect } from '@wordpress/data';
import { Stepper, TextControl, ImageUpload } from '@woocommerce/components';
import { getHistory, getNewPath } from '@woocommerce/navigation';
import {
OPTIONS_STORE_NAME,
ONBOARDING_STORE_NAME,
WC_ADMIN_NAMESPACE,
} from '@woocommerce/data';
import { queueRecordEvent, recordEvent } from '@woocommerce/tracks';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
class Appearance extends Component {
constructor( props ) {
super( props );
const { hasHomepage, hasProducts } = props.tasksStatus;
this.stepVisibility = {
homepage: ! hasHomepage,
import: ! hasProducts,
};
this.state = {
isDirty: false,
isPending: false,
logo: null,
stepIndex: 0,
isUpdatingLogo: false,
isUpdatingNotice: false,
storeNoticeText: props.demoStoreNotice || '',
};
this.completeStep = this.completeStep.bind( this );
this.createHomepage = this.createHomepage.bind( this );
this.importProducts = this.importProducts.bind( this );
this.updateLogo = this.updateLogo.bind( this );
this.updateNotice = this.updateNotice.bind( this );
}
componentDidMount() {
const { themeMods } = this.props.tasksStatus;
if ( themeMods && themeMods.custom_logo ) {
/* eslint-disable react/no-did-mount-set-state */
this.setState( { logo: { id: themeMods.custom_logo } } );
/* eslint-enable react/no-did-mount-set-state */
}
}
componentDidUpdate( prevProps ) {
const { isPending, logo } = this.state;
const { demoStoreNotice } = this.props;
if ( logo && ! logo.url && ! isPending ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( { isPending: true } );
wp.media
.attachment( logo.id )
.fetch()
.then( () => {
const logoUrl = wp.media.attachment( logo.id ).get( 'url' );
this.setState( {
isPending: false,
logo: { id: logo.id, url: logoUrl },
} );
} );
/* eslint-enable react/no-did-update-set-state */
}
if (
demoStoreNotice &&
prevProps.demoStoreNotice !== demoStoreNotice
) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
storeNoticeText: demoStoreNotice,
} );
/* eslint-enable react/no-did-update-set-state */
}
}
completeStep() {
const { stepIndex } = this.state;
const nextStep = this.getSteps()[ stepIndex + 1 ];
if ( nextStep ) {
this.setState( { stepIndex: stepIndex + 1 } );
} else {
getHistory().push( getNewPath( {}, '/', {} ) );
}
}
importProducts() {
const { clearTaskStatusCache, createNotice } = this.props;
this.setState( { isPending: true } );
recordEvent( 'tasklist_appearance_import_demo', {} );
apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/import_sample_products`,
method: 'POST',
} )
.then( ( result ) => {
if ( result.failed && result.failed.length ) {
createNotice(
'error',
__(
'There was an error importing some of the sample products',
'woocommerce-admin'
)
);
} else {
createNotice(
'success',
__(
'All sample products have been imported',
'woocommerce-admin'
)
);
clearTaskStatusCache();
}
this.setState( { isPending: false } );
this.completeStep();
} )
.catch( ( error ) => {
createNotice( 'error', error.message );
this.setState( { isPending: false } );
} );
}
createHomepage() {
const { clearTaskStatusCache, createNotice } = this.props;
this.setState( { isPending: true } );
recordEvent( 'tasklist_appearance_create_homepage', {
create_homepage: true,
} );
apiFetch( {
path: '/wc-admin/onboarding/tasks/create_homepage',
method: 'POST',
} )
.then( ( response ) => {
clearTaskStatusCache();
createNotice( response.status, response.message, {
actions: response.edit_post_link
? [
{
label: __(
'Customize',
'woocommerce-admin'
),
onClick: () => {
queueRecordEvent(
'tasklist_appearance_customize_homepage',
{}
);
window.location = `${ response.edit_post_link }&wc_onboarding_active_task=homepage`;
},
},
]
: null,
} );
this.setState( { isPending: false } );
this.completeStep();
} )
.catch( ( error ) => {
createNotice( 'error', error.message );
this.setState( { isPending: false } );
} );
}
async updateLogo() {
const {
clearTaskStatusCache,
createNotice,
stylesheet,
themeMods,
updateOptions,
} = this.props;
const { logo } = this.state;
const updatedThemeMods = {
...themeMods,
custom_logo: logo ? logo.id : null,
};
recordEvent( 'tasklist_appearance_upload_logo' );
this.setState( { isUpdatingLogo: true } );
const update = await updateOptions( {
[ `theme_mods_${ stylesheet }` ]: updatedThemeMods,
} );
clearTaskStatusCache();
if ( update.success ) {
this.setState( { isUpdatingLogo: false } );
createNotice(
'success',
__( 'Store logo updated sucessfully', 'woocommerce-admin' )
);
this.completeStep();
} else {
createNotice( 'error', update.message );
}
}
async updateNotice() {
const {
clearTaskStatusCache,
createNotice,
updateOptions,
} = this.props;
const { storeNoticeText } = this.state;
recordEvent( 'tasklist_appearance_set_store_notice', {
added_text: Boolean( storeNoticeText.length ),
} );
this.setState( { isUpdatingNotice: true } );
const update = await updateOptions( {
woocommerce_task_list_appearance_complete: true,
woocommerce_demo_store: storeNoticeText.length ? 'yes' : 'no',
woocommerce_demo_store_notice: storeNoticeText,
} );
clearTaskStatusCache();
if ( update.success ) {
this.setState( { isUpdatingNotice: false } );
createNotice(
'success',
__(
"🎨 Your store is looking great! Don't forget to continue personalizing it",
'woocommerce-admin'
)
);
this.completeStep();
} else {
createNotice( 'error', update.message );
}
}
getSteps() {
const {
isDirty,
isPending,
logo,
storeNoticeText,
isUpdatingLogo,
} = this.state;
const steps = [
{
key: 'import',
label: __( 'Import sample products', 'woocommerce-admin' ),
description: __(
'Well add some products that will make it easier to see what your store looks like',
'woocommerce-admin'
),
content: (
<Fragment>
<Button
onClick={ this.importProducts }
isBusy={ isPending }
isPrimary
>
{ __( 'Import products', 'woocommerce-admin' ) }
</Button>
<Button onClick={ () => this.completeStep() }>
{ __( 'Skip', 'woocommerce-admin' ) }
</Button>
</Fragment>
),
visible: this.stepVisibility.import,
},
{
key: 'homepage',
label: __( 'Create a custom homepage', 'woocommerce-admin' ),
description: __(
'Create a new homepage and customize it to suit your needs',
'woocommerce-admin'
),
content: (
<Fragment>
<Button
isPrimary
isBusy={ isPending }
onClick={ this.createHomepage }
>
{ __( 'Create homepage', 'woocommerce-admin' ) }
</Button>
<Button
isTertiary
onClick={ () => {
recordEvent(
'tasklist_appearance_create_homepage',
{ create_homepage: false }
);
this.completeStep();
} }
>
{ __( 'Skip', 'woocommerce-admin' ) }
</Button>
</Fragment>
),
visible: this.stepVisibility.homepage,
},
{
key: 'logo',
label: __( 'Upload a logo', 'woocommerce-admin' ),
description: __(
'Ensure your store is on-brand by adding your logo',
'woocommerce-admin'
),
content: isPending ? null : (
<Fragment>
<ImageUpload
image={ logo }
onChange={ ( image ) =>
this.setState( { isDirty: true, logo: image } )
}
/>
<Button
disabled={ ! logo && ! isDirty }
onClick={ this.updateLogo }
isBusy={ isUpdatingLogo }
isPrimary
>
{ __( 'Proceed', 'woocommerce-admin' ) }
</Button>
<Button
isTertiary
onClick={ () => this.completeStep() }
>
{ __( 'Skip', 'woocommerce-admin' ) }
</Button>
</Fragment>
),
visible: true,
},
{
key: 'notice',
label: __( 'Set a store notice', 'woocommerce-admin' ),
description: __(
'Optionally display a prominent notice across all pages of your store',
'woocommerce-admin'
),
content: (
<Fragment>
<TextControl
label={ __(
'Store notice text',
'woocommerce-admin'
) }
placeholder={ __(
'Store notice text',
'woocommerce-admin'
) }
value={ storeNoticeText }
onChange={ ( value ) =>
this.setState( { storeNoticeText: value } )
}
/>
<Button onClick={ this.updateNotice } isPrimary>
{ __( 'Complete task', 'woocommerce-admin' ) }
</Button>
</Fragment>
),
visible: true,
},
];
return filter( steps, ( step ) => step.visible );
}
render() {
const {
isPending,
stepIndex,
isUpdatingLogo,
isUpdatingNotice,
} = this.state;
const currentStep = this.getSteps()[ stepIndex ].key;
return (
<div className="woocommerce-task-appearance">
<Card className="woocommerce-task-card">
<CardBody>
<Stepper
isPending={
isUpdatingNotice || isUpdatingLogo || isPending
}
isVertical
currentStep={ currentStep }
steps={ this.getSteps() }
/>
</CardBody>
</Card>
</div>
);
}
}
const AppearanceWrapper = compose(
withSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
const { getTasksStatus } = select( ONBOARDING_STORE_NAME );
const tasksStatus = getTasksStatus();
return {
demoStoreNotice: getOption( 'woocommerce_demo_store_notice' ),
stylesheet: getOption( 'stylesheet' ),
tasksStatus,
};
} ),
withDispatch( ( dispatch ) => {
const { createNotice } = dispatch( 'core/notices' );
const { updateOptions } = dispatch( OPTIONS_STORE_NAME );
const { invalidateResolutionForStoreSelector } = dispatch(
ONBOARDING_STORE_NAME
);
return {
clearTaskStatusCache: () =>
invalidateResolutionForStoreSelector( 'getTasksStatus' ),
createNotice,
updateOptions,
};
} )
)( Appearance );
registerPlugin( 'wc-admin-onboarding-task-appearance', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="appearance">
<AppearanceWrapper />
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,148 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import apiFetch from '@wordpress/api-fetch';
import { withDispatch, withSelect } from '@wordpress/data';
import { omit } from 'lodash';
import { getHistory, getNewPath } from '@woocommerce/navigation';
import { ONBOARDING_STORE_NAME, WC_ADMIN_NAMESPACE } from '@woocommerce/data';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
class Connect extends Component {
componentDidMount() {
document.body.classList.add( 'woocommerce-admin-is-loading' );
const { query } = this.props;
if ( query.deny === '1' ) {
this.errorMessage(
__(
'You must click approve to install your extensions and connect to WooCommerce.com',
'woocommerce-admin'
)
);
return;
}
if ( ! query[ 'wccom-connected' ] || ! query.request_token ) {
this.request();
return;
}
this.finish();
}
baseQuery() {
const { query } = this.props;
const baseQuery = omit( { ...query, page: 'wc-admin' }, [
'task',
'wccom-connected',
'request_token',
'deny',
] );
return getNewPath( {}, '/', baseQuery );
}
errorMessage(
message = __(
'There was an error connecting to WooCommerce.com. Please try again',
'woocommerce-admin'
)
) {
document.body.classList.remove( 'woocommerce-admin-is-loading' );
getHistory().push( this.baseQuery() );
this.props.createNotice( 'error', message );
}
async request() {
try {
const connectResponse = await apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/plugins/request-wccom-connect`,
method: 'POST',
} );
if ( connectResponse && connectResponse.connectAction ) {
window.location = connectResponse.connectAction;
return;
}
throw new Error();
} catch ( err ) {
this.errorMessage();
}
}
async finish() {
const { query } = this.props;
try {
const connectResponse = await apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/plugins/finish-wccom-connect`,
method: 'POST',
data: {
request_token: query.request_token,
},
} );
if ( connectResponse && connectResponse.success ) {
await this.props.updateProfileItems( {
wccom_connected: true,
} );
if ( ! this.props.isProfileItemsError ) {
this.props.createNotice(
'success',
__(
'Store connected to WooCommerce.com and extensions are being installed',
'woocommerce-admin'
)
);
// @todo Show a notice for when extensions are correctly installed.
document.body.classList.remove(
'woocommerce-admin-is-loading'
);
getHistory().push( this.baseQuery() );
} else {
this.errorMessage();
}
return;
}
throw new Error();
} catch ( err ) {
this.errorMessage();
}
}
render() {
return null;
}
}
const ConnectWrapper = compose(
withSelect( ( select ) => {
const { getOnboardingError } = select( ONBOARDING_STORE_NAME );
const isProfileItemsError = Boolean(
getOnboardingError( 'updateProfileItems' )
);
return { isProfileItemsError };
} ),
withDispatch( ( dispatch ) => {
const { createNotice } = dispatch( 'core/notices' );
const { updateProfileItems } = dispatch( ONBOARDING_STORE_NAME );
return {
createNotice,
updateProfileItems,
};
} )
)( Connect );
registerPlugin( 'wc-admin-onboarding-task-connect', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="connect">
<ConnectWrapper />
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import './PaymentGatewaySuggestions';
import './shipping';
import './Marketing';
import './products';
import './appearance';
import './connect';
import './tax';
import './woocommerce-payments';
import './purchase';

View File

@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import './products';
import ProductTemplateModal from './product-template-modal';
export { ProductTemplateModal };

View File

@ -0,0 +1,134 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Modal, RadioControl } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { applyFilters } from '@wordpress/hooks';
import { ITEMS_STORE_NAME } from '@woocommerce/data';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './product-template-modal.scss';
import { createNoticesFromResponse } from '../../../lib/notices';
export const ONBOARDING_PRODUCT_TEMPLATES_FILTER =
'woocommerce_admin_onboarding_product_templates';
const PRODUCT_TEMPLATES = [
{
key: 'physical',
title: __( 'Physical product', 'woocommerce-admin' ),
subtitle: __(
'Tangible items that get delivered to customers',
'woocommerce-admin'
),
},
{
key: 'digital',
title: __( 'Digital product', 'woocommerce-admin' ),
subtitle: __(
'Items that customers download or access through your website',
'woocommerce-admin'
),
},
{
key: 'variable',
title: __( 'Variable product', 'woocommerce-admin' ),
subtitle: __(
'Products with several versions that customers can choose from',
'woocommerce-admin'
),
},
];
export default function ProductTemplateModal( { onClose } ) {
const [ selectedTemplate, setSelectedTemplate ] = useState( null );
const [ isRedirecting, setIsRedirecting ] = useState( false );
const { createProductFromTemplate } = useDispatch( ITEMS_STORE_NAME );
const createTemplate = () => {
setIsRedirecting( true );
recordEvent( 'tasklist_product_template_selection', {
product_type: selectedTemplate,
} );
if ( selectedTemplate ) {
createProductFromTemplate(
{
template_name: selectedTemplate,
status: 'draft',
},
{ _fields: [ 'id' ] }
).then(
( data ) => {
if ( data && data.id ) {
const link = getAdminLink(
`post.php?post=${ data.id }&action=edit&wc_onboarding_active_task=products&tutorial=true`
);
window.location = link;
}
},
( error ) => {
// failed creating product with template
createNoticesFromResponse( error );
setIsRedirecting( false );
}
);
} else if ( onClose ) {
recordEvent( 'tasklist_product_template_dismiss' );
onClose();
}
};
const templates = applyFilters(
ONBOARDING_PRODUCT_TEMPLATES_FILTER,
PRODUCT_TEMPLATES
);
return (
<Modal
title={ __( 'Start with a template' ) }
isDismissible={ true }
onRequestClose={ () => onClose() }
className="woocommerce-product-template-modal"
>
<div className="woocommerce-product-template-modal__wrapper">
<div className="woocommerce-product-template-modal__list">
<RadioControl
selected={ selectedTemplate }
options={ templates.map( ( item ) => {
return {
label: (
<>
<span className="woocommerce-product-template-modal__list-title">
{ item.title }
</span>
<span className="woocommerce-product-template-modal__list-subtitle">
{ item.subtitle }
</span>
</>
),
value: item.key,
};
} ) }
onChange={ setSelectedTemplate }
/>
</div>
<div className="woocommerce-product-template-modal__actions">
<Button
isPrimary
isBusy={ isRedirecting }
disabled={ ! selectedTemplate || isRedirecting }
onClick={ createTemplate }
>
{ __( 'Go' ) }
</Button>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,53 @@
$border-color: $gray-100;
.woocommerce-product-template-modal {
@include breakpoint( '>600px' ) {
min-width: 565px;
}
.woocommerce-product-template-modal__actions {
padding-top: $gap-large;
}
}
.woocommerce-product-template-modal__list {
.components-base-control {
margin: 0 -#{$gap-large};
.components-base-control__field {
.components-radio-control__option {
display: flex;
text-decoration: none;
width: 100%;
align-items: center;
padding: $gap $gap-large;
margin-bottom: 0;
box-sizing: border-box;
border-bottom: 1px solid $gray-100;
&:first-child {
border-top: 1px solid $gray-100;
}
.components-radio-control__input {
margin-right: $gap;
flex: none;
&:checked::before {
box-sizing: border-box;
}
&:focus {
box-shadow: none;
}
}
}
}
}
.woocommerce-product-template-modal__list-title {
color: $gray-900;
display: block;
}
.woocommerce-product-template-modal__list-subtitle {
color: $gray-700;
margin-top: 4px;
display: block;
}
}
.woocommerce-product-template-modal__actions {
text-align: right;
}

View File

@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment, useState } from '@wordpress/element';
import { Card, CardBody } from '@wordpress/components';
import {
Icon,
sidebar,
chevronRight,
plusCircle,
archive,
download,
} from '@wordpress/icons';
import { List, Pill } from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { recordEvent } from '@woocommerce/tracks';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import ProductTemplateModal from './product-template-modal';
const subTasks = [
{
key: 'addProductTemplate',
title: (
<>
{ __( 'Start with a template', 'woocommerce-admin' ) }
<Pill>{ __( 'Recommended', 'woocommerce-admin' ) }</Pill>
</>
),
content: __(
'Use a template to add physical, digital, and variable products',
'woocommerce-admin'
),
before: <Icon icon={ sidebar }></Icon>,
after: <Icon icon={ chevronRight } />,
onClick: () =>
recordEvent( 'tasklist_add_product', {
method: 'product_template',
} ),
},
{
key: 'addProductManually',
title: __( 'Add manually', 'woocommerce-admin' ),
content: __(
'For small stores we recommend adding products manually',
'woocommerce-admin'
),
before: <Icon icon={ plusCircle } />,
after: <Icon icon={ chevronRight } />,
onClick: () =>
recordEvent( 'tasklist_add_product', { method: 'manually' } ),
href: getAdminLink(
'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true'
),
},
{
key: 'importProducts',
title: __( 'Import via CSV', 'woocommerce-admin' ),
content: __(
'For larger stores we recommend importing all products at once via CSV file',
'woocommerce-admin'
),
before: <Icon icon={ archive } />,
after: <Icon icon={ chevronRight } />,
onClick: () =>
recordEvent( 'tasklist_add_product', { method: 'import' } ),
href: getAdminLink(
'edit.php?post_type=product&page=product_importer&wc_onboarding_active_task=product-import'
),
},
{
key: 'migrateProducts',
title: __( 'Import from another service', 'woocommerce-admin' ),
content: __(
'For stores currently selling elsewhere we suggest using a product migration service',
'woocommerce-admin'
),
before: <Icon icon={ download } />,
after: <Icon icon={ chevronRight } />,
onClick: () =>
recordEvent( 'tasklist_add_product', { method: 'migrate' } ),
// @todo This should be replaced with the in-app purchase iframe when ready.
href: 'https://woocommerce.com/products/cart2cart/?utm_medium=product',
target: '_blank',
},
];
const Products = () => {
const [ selectTemplate, setSelectTemplate ] = useState( null );
const onTaskClick = ( task ) => {
task.onClick();
if ( task.key === 'addProductTemplate' ) {
setSelectTemplate( true );
}
};
const listItems = subTasks.map( ( task ) => ( {
...task,
onClick: () => onTaskClick( task ),
} ) );
return (
<Fragment>
<Card className="woocommerce-task-card">
<CardBody size={ null }>
<List items={ listItems } />
</CardBody>
</Card>
{ selectTemplate ? (
<ProductTemplateModal
onClose={ () => setSelectTemplate( null ) }
/>
) : null }
</Fragment>
);
};
registerPlugin( 'wc-admin-onboarding-task-products', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="products">
<Products />
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import React from 'react';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
import { useState, useCallback } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
import { useSelect } from '@wordpress/data';
import { ONBOARDING_STORE_NAME, PLUGINS_STORE_NAME } from '@woocommerce/data';
/**
* Internal dependencies
*/
import CartModal from '../../dashboard/components/cart-modal';
import { getCategorizedOnboardingProducts } from '../../dashboard/utils';
const PurchaseTaskItem = () => {
const [ cartModalOpen, setCartModalOpen ] = useState( false );
const { installedPlugins, profileItems } = useSelect( ( select ) => {
const { getProfileItems } = select( ONBOARDING_STORE_NAME );
const { getInstalledPlugins } = select( PLUGINS_STORE_NAME );
return {
installedPlugins: getInstalledPlugins(),
profileItems: getProfileItems(),
};
} );
const toggleCartModal = useCallback( () => {
if ( ! cartModalOpen ) {
recordEvent( 'tasklist_purchase_extensions' );
}
setCartModalOpen( ! cartModalOpen );
}, [ cartModalOpen ] );
const groupedProducts = getCategorizedOnboardingProducts(
profileItems,
installedPlugins
);
const { remainingProducts } = groupedProducts;
return (
<WooOnboardingTaskListItem id="purchase">
{ ( { defaultTaskItem: DefaultTaskItem } ) => (
<>
<DefaultTaskItem
onClick={ () => {
if ( remainingProducts.length ) {
toggleCartModal();
}
} }
/>
{ cartModalOpen && (
<CartModal
onClose={ () => toggleCartModal() }
onClickPurchaseLater={ () => toggleCartModal() }
/>
) }
</>
) }
</WooOnboardingTaskListItem>
);
};
registerPlugin( 'woocommerce-admin-task-purchase', {
scope: 'woocommerce-tasks',
render: PurchaseTaskItem,
} );

View File

@ -0,0 +1,383 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { Component } from '@wordpress/element';
import { Card, CardBody } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import { difference, filter } from 'lodash';
import interpolateComponents from 'interpolate-components';
import { withDispatch, withSelect } from '@wordpress/data';
import { Link, Stepper, Plugins } from '@woocommerce/components';
import { getAdminLink, getSetting } from '@woocommerce/wc-admin-settings';
import { getHistory, getNewPath } from '@woocommerce/navigation';
import { SETTINGS_STORE_NAME, PLUGINS_STORE_NAME } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import Connect from '../../../dashboard/components/connect';
import { getCountryCode } from '../../../dashboard/utils';
import StoreLocation from '../steps/location';
import ShippingRates from './rates';
import { createNoticesFromResponse } from '../../../lib/notices';
export class Shipping extends Component {
constructor( props ) {
super( props );
this.initialState = {
isPending: false,
step: 'store_location',
shippingZones: [],
};
// Cache active plugins to prevent removal mid-step.
this.activePlugins = props.activePlugins;
this.state = this.initialState;
this.completeStep = this.completeStep.bind( this );
}
componentDidMount() {
this.reset();
}
reset() {
this.setState( this.initialState );
}
async fetchShippingZones() {
this.setState( { isPending: true } );
const { countryCode, countryName } = this.props;
// @todo The following fetches for shipping information should be moved into
// @woocommerce/data to make these methods and states more readily available.
const shippingZones = [];
const zones = await apiFetch( { path: '/wc/v3/shipping/zones' } );
let hasCountryZone = false;
await Promise.all(
zones.map( async ( zone ) => {
// "Rest of the world zone"
if ( zone.id === 0 ) {
zone.methods = await apiFetch( {
path: `/wc/v3/shipping/zones/${ zone.id }/methods`,
} );
zone.name = __( 'Rest of the world', 'woocommerce-admin' );
zone.toggleable = true;
shippingZones.push( zone );
return;
}
// Return any zone with a single location matching the country zone.
zone.locations = await apiFetch( {
path: `/wc/v3/shipping/zones/${ zone.id }/locations`,
} );
const countryLocation = zone.locations.find(
( location ) => countryCode === location.code
);
if ( countryLocation ) {
zone.methods = await apiFetch( {
path: `/wc/v3/shipping/zones/${ zone.id }/methods`,
} );
shippingZones.push( zone );
hasCountryZone = true;
}
} )
);
// Create the default store country zone if it doesn't exist.
if ( ! hasCountryZone ) {
const zone = await apiFetch( {
method: 'POST',
path: '/wc/v3/shipping/zones',
data: { name: countryName },
} );
zone.locations = await apiFetch( {
method: 'POST',
path: `/wc/v3/shipping/zones/${ zone.id }/locations`,
data: [ { code: countryCode, type: 'country' } ],
} );
shippingZones.push( zone );
}
shippingZones.reverse();
this.setState( { isPending: false, shippingZones } );
}
componentDidUpdate( prevProps, prevState ) {
const { countryCode, settings } = this.props;
const {
woocommerce_store_address: storeAddress,
woocommerce_default_country: defaultCountry,
woocommerce_store_postcode: storePostCode,
} = settings;
const { step } = this.state;
if (
step === 'rates' &&
( prevProps.countryCode !== countryCode ||
prevState.step !== 'rates' )
) {
this.fetchShippingZones();
}
const isCompleteAddress = Boolean(
storeAddress && defaultCountry && storePostCode
);
if ( step === 'store_location' && isCompleteAddress ) {
this.completeStep();
}
}
completeStep() {
const { createNotice } = this.props;
const { step } = this.state;
const steps = this.getSteps();
const currentStepIndex = steps.findIndex( ( s ) => s.key === step );
const nextStep = steps[ currentStepIndex + 1 ];
if ( nextStep ) {
this.setState( { step: nextStep.key } );
} else {
createNotice(
'success',
__(
"📦 Shipping is done! Don't worry, you can always change it later",
'woocommerce-admin'
)
);
getHistory().push( getNewPath( {}, '/', {} ) );
}
}
getPluginsToActivate() {
const { countryCode } = this.props;
const plugins = [];
if ( [ 'GB', 'CA', 'AU' ].includes( countryCode ) ) {
plugins.push( 'woocommerce-shipstation-integration' );
} else if ( countryCode === 'US' ) {
plugins.push( 'woocommerce-services' );
plugins.push( 'jetpack' );
}
return difference( plugins, this.activePlugins );
}
getSteps() {
const { countryCode, isJetpackConnected, settings } = this.props;
const pluginsToActivate = this.getPluginsToActivate();
const requiresJetpackConnection =
! isJetpackConnected && countryCode === 'US';
const steps = [
{
key: 'store_location',
label: __( 'Set store location', 'woocommerce-admin' ),
description: __(
'The address from which your business operates',
'woocommerce-admin'
),
content: (
<StoreLocation
{ ...this.props }
onComplete={ ( values ) => {
const country = getCountryCode(
values.countryState
);
recordEvent( 'tasklist_shipping_set_location', {
country,
} );
this.completeStep();
} }
/>
),
visible: true,
},
{
key: 'rates',
label: __( 'Set shipping costs', 'woocommerce-admin' ),
description: __(
'Define how much customers pay to ship to different destinations',
'woocommerce-admin'
),
content: (
<ShippingRates
buttonText={
pluginsToActivate.length ||
requiresJetpackConnection
? __( 'Proceed', 'woocommerce-admin' )
: __( 'Complete task', 'woocommerce-admin' )
}
shippingZones={ this.state.shippingZones }
onComplete={ this.completeStep }
{ ...this.props }
/>
),
visible:
settings.woocommerce_ship_to_countries === 'disabled'
? false
: true,
},
{
key: 'label_printing',
label: __(
'Enable shipping label printing',
'woocommerce-admin'
),
description: pluginsToActivate.includes(
'woocommerce-shipstation-integration'
)
? interpolateComponents( {
mixedString: __(
'We recommend using ShipStation to save time at the post office by printing your shipping ' +
'labels at home. Try ShipStation free for 30 days. {{link}}Learn more{{/link}}.',
'woocommerce-admin'
),
components: {
link: (
<Link
href="https://woocommerce.com/products/shipstation-integration?utm_medium=product"
target="_blank"
type="external"
/>
),
},
} )
: __(
'With WooCommerce Shipping you can save time ' +
'by printing your USPS and DHL Express shipping labels at home',
'woocommerce-admin'
),
content: (
<Plugins
onComplete={ ( plugins, response ) => {
createNoticesFromResponse( response );
recordEvent( 'tasklist_shipping_label_printing', {
install: true,
plugins_to_activate: pluginsToActivate,
} );
this.completeStep();
} }
onError={ ( errors, response ) =>
createNoticesFromResponse( response )
}
onSkip={ () => {
recordEvent( 'tasklist_shipping_label_printing', {
install: false,
plugins_to_activate: pluginsToActivate,
} );
getHistory().push( getNewPath( {}, '/', {} ) );
} }
pluginSlugs={ pluginsToActivate }
{ ...this.props }
/>
),
visible: pluginsToActivate.length,
},
{
key: 'connect',
label: __( 'Connect your store', 'woocommerce-admin' ),
description: __(
'Connect your store to WordPress.com to enable label printing',
'woocommerce-admin'
),
content: (
<Connect
redirectUrl={ getAdminLink(
'admin.php?page=wc-admin'
) }
completeStep={ this.completeStep }
{ ...this.props }
onConnect={ () => {
recordEvent( 'tasklist_shipping_connect_store' );
} }
/>
),
visible: requiresJetpackConnection,
},
];
return filter( steps, ( step ) => step.visible );
}
render() {
const { isPending, step } = this.state;
const { isUpdateSettingsRequesting } = this.props;
return (
<div className="woocommerce-task-shipping">
<Card className="woocommerce-task-card">
<CardBody>
<Stepper
isPending={
isPending || isUpdateSettingsRequesting
}
isVertical
currentStep={ step }
steps={ this.getSteps() }
/>
</CardBody>
</Card>
</div>
);
}
}
const ShippingWrapper = compose(
withSelect( ( select ) => {
const { getSettings, isUpdateSettingsRequesting } = select(
SETTINGS_STORE_NAME
);
const { getActivePlugins, isJetpackConnected } = select(
PLUGINS_STORE_NAME
);
const { general: settings = {} } = getSettings( 'general' );
const countryCode = getCountryCode(
settings.woocommerce_default_country
);
const { countries = [] } = getSetting( 'dataEndpoints', {} );
const country = countryCode
? countries.find( ( c ) => c.code === countryCode )
: null;
const countryName = country ? country.name : null;
const activePlugins = getActivePlugins();
return {
countryCode,
countryName,
isUpdateSettingsRequesting: isUpdateSettingsRequesting( 'general' ),
settings,
activePlugins,
isJetpackConnected: isJetpackConnected(),
};
} ),
withDispatch( ( dispatch ) => {
const { createNotice } = dispatch( 'core/notices' );
const { updateAndPersistSettingsForGroup } = dispatch(
SETTINGS_STORE_NAME
);
return {
createNotice,
updateAndPersistSettingsForGroup,
};
} )
)( Shipping );
registerPlugin( 'wc-admin-onboarding-task-shipping', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="shipping">
<ShippingWrapper />
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,369 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { compose } from '@wordpress/compose';
import { withDispatch } from '@wordpress/data';
import { Component, Fragment } from '@wordpress/element';
import { Button, FormToggle } from '@wordpress/components';
import PropTypes from 'prop-types';
import { Flag, Form, TextControlWithAffixes } from '@woocommerce/components';
import { ONBOARDING_STORE_NAME } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { Icon, globe } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { CurrencyContext } from '../../../lib/currency-context';
class ShippingRates extends Component {
constructor() {
super( ...arguments );
this.updateShippingZones = this.updateShippingZones.bind( this );
}
getShippingMethods( zone, type = null ) {
// Sometimes the wc/v3/shipping/zones response does not include a methods attribute, return early if so.
if ( ! zone || ! zone.methods || ! Array.isArray( zone.methods ) ) {
return [];
}
if ( ! type ) {
return zone.methods;
}
return zone.methods
? zone.methods.filter( ( method ) => method.method_id === type )
: [];
}
disableShippingMethods( zone, methods ) {
if ( ! methods.length ) {
return;
}
methods.forEach( ( method ) => {
apiFetch( {
method: 'POST',
path: `/wc/v3/shipping/zones/${ zone.id }/methods/${ method.instance_id }`,
data: {
enabled: false,
},
} );
} );
}
async updateShippingZones( values ) {
const {
clearTaskStatusCache,
createNotice,
shippingZones,
} = this.props;
let restOfTheWorld = false;
let shippingCost = false;
shippingZones.forEach( ( zone ) => {
if ( zone.id === 0 ) {
restOfTheWorld =
zone.toggleable && values[ `${ zone.id }_enabled` ];
} else {
shippingCost =
values[ `${ zone.id }_rate` ] !== '' &&
parseFloat( values[ `${ zone.id }_rate` ] ) !==
parseFloat( 0 );
}
const shippingMethods = this.getShippingMethods( zone );
const methodType =
parseFloat( values[ `${ zone.id }_rate` ] ) === parseFloat( 0 )
? 'free_shipping'
: 'flat_rate';
const shippingMethod = this.getShippingMethods( zone, methodType )
.length
? this.getShippingMethods( zone, methodType )[ 0 ]
: null;
if ( zone.toggleable && ! values[ `${ zone.id }_enabled` ] ) {
// Disable any shipping methods that exist if toggled off.
this.disableShippingMethods( zone, shippingMethods );
return;
} else if ( shippingMethod ) {
// Disable all methods except the one being updated.
const methodsToDisable = shippingMethods.filter(
( method ) =>
method.instance_id !== shippingMethod.instance_id
);
this.disableShippingMethods( zone, methodsToDisable );
}
apiFetch( {
method: 'POST',
path: shippingMethod
? // Update the first existing method if one exists, otherwise create a new one.
`/wc/v3/shipping/zones/${ zone.id }/methods/${ shippingMethod.instance_id }`
: `/wc/v3/shipping/zones/${ zone.id }/methods`,
data: {
method_id: methodType,
enabled: true,
settings: { cost: values[ `${ zone.id }_rate` ] },
},
} );
} );
recordEvent( 'tasklist_shipping_set_costs', {
shipping_cost: shippingCost,
rest_world: restOfTheWorld,
} );
clearTaskStatusCache();
createNotice(
'success',
__( 'Your shipping rates have been updated', 'woocommerce-admin' )
);
this.props.onComplete();
}
renderInputPrefix() {
const { symbolPosition, symbol } = this.context.getCurrencyConfig();
if ( symbolPosition.indexOf( 'right' ) === 0 ) {
return null;
}
return (
<span className="woocommerce-shipping-rate__control-prefix">
{ symbol }
</span>
);
}
renderInputSuffix( rate ) {
const { symbolPosition, symbol } = this.context.getCurrencyConfig();
if ( symbolPosition.indexOf( 'right' ) === 0 ) {
return (
<span className="woocommerce-shipping-rate__control-suffix">
{ symbol }
</span>
);
}
return parseFloat( rate ) === parseFloat( 0 ) ? (
<span className="woocommerce-shipping-rate__control-suffix">
{ __( 'Free shipping', 'woocommerce-admin' ) }
</span>
) : null;
}
getFormattedRate( value ) {
const { formatDecimalString } = this.context;
const currencyString = formatDecimalString( value );
if ( ! value.length || ! currencyString.length ) {
return formatDecimalString( 0 );
}
return formatDecimalString( value );
}
getInitialValues() {
const { formatDecimalString } = this.context;
const values = {};
this.props.shippingZones.forEach( ( zone ) => {
const shippingMethods = this.getShippingMethods( zone );
const rate =
shippingMethods.length && shippingMethods[ 0 ].settings.cost
? this.getFormattedRate(
shippingMethods[ 0 ].settings.cost.value
)
: formatDecimalString( 0 );
values[ `${ zone.id }_rate` ] = rate;
if ( shippingMethods.length && shippingMethods[ 0 ].enabled ) {
values[ `${ zone.id }_enabled` ] = true;
} else {
values[ `${ zone.id }_enabled` ] = false;
}
} );
return values;
}
validate( values ) {
const errors = {};
const rates = Object.keys( values ).filter( ( field ) =>
field.endsWith( '_rate' )
);
rates.forEach( ( rate ) => {
if ( values[ rate ] < 0 ) {
errors[ rate ] = __(
'Shipping rates can not be negative numbers.',
'woocommerce-admin'
);
}
} );
return errors;
}
render() {
const { buttonText, shippingZones } = this.props;
if ( ! shippingZones.length ) {
return null;
}
return (
<Form
initialValues={ this.getInitialValues() }
onSubmit={ this.updateShippingZones }
validate={ this.validate }
>
{ ( {
getInputProps,
handleSubmit,
setTouched,
setValue,
values,
} ) => {
return (
<Fragment>
<div className="woocommerce-shipping-rates">
{ shippingZones.map( ( zone ) => (
<div
className="woocommerce-shipping-rate"
key={ zone.id }
>
<div className="woocommerce-shipping-rate__icon">
{ zone.locations ? (
zone.locations.map(
( location ) => (
<Flag
size={ 24 }
code={
location.code
}
key={
location.code
}
/>
)
)
) : (
// Icon used for zones without locations or "Rest of the world".
<Icon icon={ globe } />
) }
</div>
<div className="woocommerce-shipping-rate__main">
{ zone.toggleable ? (
<label
htmlFor={ `woocommerce-shipping-rate__toggle-${ zone.id }` }
className="woocommerce-shipping-rate__name"
>
{ zone.name }
<FormToggle
id={ `woocommerce-shipping-rate__toggle-${ zone.id }` }
{ ...getInputProps(
`${ zone.id }_enabled`
) }
/>
</label>
) : (
<div className="woocommerce-shipping-rate__name">
{ zone.name }
</div>
) }
{ ( ! zone.toggleable ||
values[
`${ zone.id }_enabled`
] ) && (
<TextControlWithAffixes
label={ __(
'Shipping cost',
'woocommerce-admin'
) }
required
{ ...getInputProps(
`${ zone.id }_rate`
) }
onBlur={ () => {
setTouched(
`${ zone.id }_rate`
);
setValue(
`${ zone.id }_rate`,
this.getFormattedRate(
values[
`${ zone.id }_rate`
]
)
);
} }
prefix={ this.renderInputPrefix() }
suffix={ this.renderInputSuffix(
values[
`${ zone.id }_rate`
]
) }
className="muriel-input-text woocommerce-shipping-rate__control-wrapper"
/>
) }
</div>
</div>
) ) }
</div>
<Button isPrimary onClick={ handleSubmit }>
{ buttonText ||
__( 'Update', 'woocommerce-admin' ) }
</Button>
</Fragment>
);
} }
</Form>
);
}
}
ShippingRates.propTypes = {
/**
* Text displayed on the primary button.
*/
buttonText: PropTypes.string,
/**
* Function used to mark the step complete.
*/
onComplete: PropTypes.func.isRequired,
/**
* Function to create a transient notice in the store.
*/
createNotice: PropTypes.func.isRequired,
/**
* Array of shipping zones returned from the WC REST API with added
* `methods` and `locations` properties appended from separate API calls.
*/
shippingZones: PropTypes.array,
};
ShippingRates.defaultProps = {
shippingZones: [],
};
ShippingRates.contextType = CurrencyContext;
export default compose(
withDispatch( ( dispatch ) => {
const { invalidateResolutionForStoreSelector } = dispatch(
ONBOARDING_STORE_NAME
);
return {
clearTaskStatusCache: () =>
invalidateResolutionForStoreSelector( 'getTasksStatus' ),
};
} )
)( ShippingRates );

View File

@ -0,0 +1,103 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import { Form } from '@woocommerce/components';
/**
* Internal dependencies
*/
import {
StoreAddress,
validateStoreAddress,
} from '../../../dashboard/components/settings/general/store-address';
export default class StoreLocation extends Component {
constructor() {
super( ...arguments );
this.onSubmit = this.onSubmit.bind( this );
}
async onSubmit( values ) {
const {
onComplete,
createNotice,
isSettingsError,
updateAndPersistSettingsForGroup,
settings,
} = this.props;
await updateAndPersistSettingsForGroup( 'general', {
general: {
...settings,
woocommerce_store_address: values.addressLine1,
woocommerce_store_address_2: values.addressLine2,
woocommerce_default_country: values.countryState,
woocommerce_store_city: values.city,
woocommerce_store_postcode: values.postCode,
},
} );
if ( ! isSettingsError ) {
onComplete( values );
} else {
createNotice(
'error',
__(
'There was a problem saving your store location',
'woocommerce-admin'
)
);
}
}
getInitialValues() {
const { settings } = this.props;
const {
woocommerce_store_address: storeAddress,
woocommerce_store_address_2: storeAddress2,
woocommerce_store_city: storeCity,
woocommerce_default_country: defaultCountry,
woocommerce_store_postcode: storePostcode,
} = settings;
return {
addressLine1: storeAddress || '',
addressLine2: storeAddress2 || '',
city: storeCity || '',
countryState: defaultCountry || '',
postCode: storePostcode || '',
};
}
render() {
const { isSettingsRequesting } = this.props;
if ( isSettingsRequesting ) {
return null;
}
return (
<Form
initialValues={ this.getInitialValues() }
onSubmit={ this.onSubmit }
validate={ validateStoreAddress }
>
{ ( { getInputProps, handleSubmit, setValue } ) => (
<Fragment>
<StoreAddress
getInputProps={ getInputProps }
setValue={ setValue }
/>
<Button isPrimary onClick={ handleSubmit }>
{ __( 'Continue', 'woocommerce-admin' ) }
</Button>
</Fragment>
) }
</Form>
);
}
}

View File

@ -0,0 +1,599 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Card, CardBody } from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { difference, filter } from 'lodash';
import interpolateComponents from 'interpolate-components';
import { withDispatch, withSelect } from '@wordpress/data';
import { H, Link, Stepper, Plugins, Spinner } from '@woocommerce/components';
import { getHistory, getNewPath } from '@woocommerce/navigation';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import {
ONBOARDING_STORE_NAME,
OPTIONS_STORE_NAME,
PLUGINS_STORE_NAME,
SETTINGS_STORE_NAME,
} from '@woocommerce/data';
import { recordEvent, queueRecordEvent } from '@woocommerce/tracks';
import { Text } from '@woocommerce/experimental';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
/**
* Internal dependencies
*/
import Connect from '../../dashboard/components/connect';
import { createNoticesFromResponse } from '../../lib/notices';
import { getCountryCode } from '../../dashboard/utils';
import StoreLocation from './steps/location';
class Tax extends Component {
constructor( props ) {
super( props );
const { hasCompleteAddress, pluginsToActivate } = props;
this.initialState = {
isPending: false,
stepIndex: hasCompleteAddress ? 1 : 0,
// Cache the value of pluginsToActivate so that we can
// show/hide tasks based on it, but not have them update mid task.
cachedPluginsToActivate: pluginsToActivate,
};
this.state = this.initialState;
this.completeStep = this.completeStep.bind( this );
}
componentDidMount() {
const { query } = this.props;
const { auto } = query;
this.reset();
if ( auto === 'true' ) {
this.enableAutomatedTax();
}
}
reset() {
this.setState( this.initialState );
}
shouldShowSuccessScreen() {
const {
isJetpackConnected,
hasCompleteAddress,
pluginsToActivate,
} = this.props;
return (
hasCompleteAddress &&
! pluginsToActivate.length &&
isJetpackConnected &&
this.isTaxJarSupported()
);
}
isTaxJarSupported() {
const { countryCode, tasksStatus } = this.props;
const {
automatedTaxSupportedCountries = [],
taxJarActivated,
} = tasksStatus;
return (
! taxJarActivated && // WCS integration doesn't work with the official TaxJar plugin.
automatedTaxSupportedCountries.includes( countryCode )
);
}
completeStep() {
const { stepIndex } = this.state;
const steps = this.getSteps();
const nextStep = steps[ stepIndex + 1 ];
if ( nextStep ) {
this.setState( { stepIndex: stepIndex + 1 } );
}
}
async manuallyConfigureTaxRates() {
const {
generalSettings,
updateAndPersistSettingsForGroup,
} = this.props;
if ( generalSettings.woocommerce_calc_taxes !== 'yes' ) {
this.setState( { isPending: true } );
updateAndPersistSettingsForGroup( 'general', {
general: {
...generalSettings,
woocommerce_calc_taxes: 'yes',
},
} )
.then( () => this.redirectToTaxSettings() )
.catch( ( error ) => createNoticesFromResponse( error ) );
} else {
this.redirectToTaxSettings();
}
}
updateAutomatedTax( isEnabling ) {
const {
clearTaskStatusCache,
createNotice,
updateAndPersistSettingsForGroup,
generalSettings,
taxSettings,
} = this.props;
Promise.all( [
updateAndPersistSettingsForGroup( 'tax', {
tax: {
...taxSettings,
wc_connect_taxes_enabled: isEnabling ? 'yes' : 'no',
},
} ),
updateAndPersistSettingsForGroup( 'general', {
general: {
...generalSettings,
woocommerce_calc_taxes: 'yes',
},
} ),
] )
.then( () => {
clearTaskStatusCache();
if ( isEnabling ) {
createNotice(
'success',
__(
"You're awesome! One less item on your to-do list ✅",
'woocommerce-admin'
)
);
getHistory().push( getNewPath( {}, '/', {} ) );
} else {
this.redirectToTaxSettings();
}
} )
.catch( () => {
createNotice(
'error',
__(
'There was a problem updating your tax settings',
'woocommerce-admin'
)
);
} );
}
redirectToTaxSettings() {
window.location = getAdminLink(
'admin.php?page=wc-settings&tab=tax&section=standard&wc_onboarding_active_task=tax'
);
}
doNotChargeSalesTax() {
const { updateOptions } = this.props;
queueRecordEvent( 'tasklist_tax_connect_store', {
connect: false,
no_tax: true,
} );
updateOptions( {
woocommerce_no_sales_tax: true,
woocommerce_calc_taxes: 'no',
} ).then( () => {
window.location = getAdminLink( 'admin.php?page=wc-admin' );
} );
}
getSteps() {
const {
generalSettings,
isJetpackConnected,
isPending,
tosAccepted,
updateOptions,
} = this.props;
const { cachedPluginsToActivate } = this.state;
let step2Label, agreementText;
if ( cachedPluginsToActivate.includes( 'woocommerce-services' ) ) {
step2Label = __(
'Install Jetpack and WooCommerce Tax',
'woocommerce-admin'
);
agreementText = __(
'By installing Jetpack and WooCommerce Tax you agree to the {{link}}Terms of Service{{/link}}.',
'woocommerce-admin'
);
} else {
step2Label = __( 'Install Jetpack', 'woocommerce-admin' );
agreementText = __(
'By installing Jetpack you agree to the {{link}}Terms of Service{{/link}}.',
'woocommerce-admin'
);
}
const steps = [
{
key: 'store_location',
label: __( 'Set store location', 'woocommerce-admin' ),
description: __(
'The address from which your business operates',
'woocommerce-admin'
),
content: (
<StoreLocation
{ ...this.props }
onComplete={ ( values ) => {
const country = getCountryCode(
values.countryState
);
recordEvent( 'tasklist_tax_set_location', {
country,
} );
this.completeStep();
} }
isSettingsRequesting={ false }
settings={ generalSettings }
/>
),
visible: true,
},
{
key: 'plugins',
label: step2Label,
description: __(
'Jetpack and WooCommerce Tax allow you to automate sales tax calculations',
'woocommerce-admin'
),
content: (
<Fragment>
<Plugins
onComplete={ ( plugins, response ) => {
createNoticesFromResponse( response );
recordEvent(
'tasklist_tax_install_extensions',
{
install_extensions: true,
}
);
updateOptions( {
woocommerce_setup_jetpack_opted_in: true,
} );
this.completeStep();
} }
onError={ ( errors, response ) =>
createNoticesFromResponse( response )
}
onSkip={ () => {
queueRecordEvent(
'tasklist_tax_install_extensions',
{
install_extensions: false,
}
);
this.manuallyConfigureTaxRates();
} }
skipText={ __(
'Set up manually',
'woocommerce-admin'
) }
onAbort={ () => this.doNotChargeSalesTax() }
abortText={ __(
"I don't charge sales tax",
'woocommerce-admin'
) }
/>
{ ! tosAccepted && (
<Text
variant="caption"
className="woocommerce-task__caption"
size="12"
lineHeight="16px"
>
{ interpolateComponents( {
mixedString: agreementText,
components: {
link: (
<Link
href={
'https://wordpress.com/tos/'
}
target="_blank"
type="external"
/>
),
},
} ) }
</Text>
) }
</Fragment>
),
visible:
( cachedPluginsToActivate.length || ! tosAccepted ) &&
this.isTaxJarSupported(),
},
{
key: 'connect',
label: __( 'Connect your store', 'woocommerce-admin' ),
description: __(
'Connect your store to WordPress.com to enable automated sales tax calculations',
'woocommerce-admin'
),
content: (
<Connect
{ ...this.props }
onConnect={ () => {
recordEvent( 'tasklist_tax_connect_store', {
connect: true,
no_tax: false,
} );
} }
onSkip={ () => {
queueRecordEvent( 'tasklist_tax_connect_store', {
connect: false,
no_tax: false,
} );
this.manuallyConfigureTaxRates();
} }
skipText={ __(
'Set up tax rates manually',
'woocommerce-admin'
) }
onAbort={ () => this.doNotChargeSalesTax() }
abortText={ __(
"My business doesn't charge sales tax",
'woocommerce-admin'
) }
/>
),
visible: ! isJetpackConnected && this.isTaxJarSupported(),
},
{
key: 'manual_configuration',
label: __( 'Configure tax rates', 'woocommerce-admin' ),
description: __(
'Head over to the tax rate settings screen to configure your tax rates',
'woocommerce-admin'
),
content: (
<Fragment>
<Button
disabled={ isPending }
isPrimary
isBusy={ isPending }
onClick={ () => {
recordEvent( 'tasklist_tax_config_rates' );
this.manuallyConfigureTaxRates();
} }
>
{ __( 'Configure', 'woocommerce-admin' ) }
</Button>
<p>
{ generalSettings.woocommerce_calc_taxes !==
'yes' &&
interpolateComponents( {
mixedString: __(
/*eslint-disable max-len*/
'By clicking "Configure" you\'re enabling tax rates and calculations. More info {{link}}here{{/link}}.',
/*eslint-enable max-len*/
'woocommerce-admin'
),
components: {
link: (
<Link
href="https://docs.woocommerce.com/document/setting-up-taxes-in-woocommerce/?utm_medium=product#section-1"
target="_blank"
type="external"
/>
),
},
} ) }
</p>
</Fragment>
),
visible: ! this.isTaxJarSupported(),
},
];
return filter( steps, ( step ) => step.visible );
}
enableAutomatedTax() {
recordEvent( 'tasklist_tax_setup_automated_proceed', {
setup_automatically: true,
} );
this.updateAutomatedTax( true );
}
renderSuccessScreen() {
const { isPending } = this.props;
return (
<div className="woocommerce-task-tax__success">
<span
className="woocommerce-task-tax__success-icon"
role="img"
aria-labelledby="woocommerce-task-tax__success-message"
>
🎊
</span>
<H id="woocommerce-task-tax__success-message">
{ __( 'Good news!', 'woocommerce-admin' ) }
</H>
<p>
{ interpolateComponents( {
mixedString: __(
'{{strong}}Jetpack{{/strong}} and {{strong}}WooCommerce Tax{{/strong}} ' +
'can automate your sales tax calculations for you.',
'woocommerce-admin'
),
components: {
strong: <strong />,
},
} ) }
</p>
<Button
disabled={ isPending }
isPrimary
isBusy={ isPending }
onClick={ this.enableAutomatedTax.bind( this ) }
>
{ __( 'Yes please', 'woocommerce-admin' ) }
</Button>
<Button
disabled={ isPending }
isTertiary
onClick={ () => {
recordEvent( 'tasklist_tax_setup_automated_proceed', {
setup_automatically: false,
} );
this.updateAutomatedTax( false );
} }
>
{ __(
"No thanks, I'll set up manually",
'woocommerce-admin'
) }
</Button>
<Button
disabled={ isPending }
isTertiary
onClick={ () => this.doNotChargeSalesTax() }
>
{ __( "I don't charge sales tax", 'woocommerce-admin' ) }
</Button>
</div>
);
}
render() {
const { stepIndex } = this.state;
const { isPending, isResolving } = this.props;
if ( isResolving ) {
return <Spinner />;
}
const step = this.getSteps()[ stepIndex ];
return (
<div className="woocommerce-task-tax">
<Card className="woocommerce-task-card">
<CardBody>
{ this.shouldShowSuccessScreen() ? (
this.renderSuccessScreen()
) : (
<Stepper
isPending={ isPending || isResolving }
isVertical={ true }
currentStep={ step.key }
steps={ this.getSteps() }
/>
) }
</CardBody>
</Card>
</div>
);
}
}
const TaxWrapper = compose(
withSelect( ( select ) => {
const { getSettings, isUpdateSettingsRequesting } = select(
SETTINGS_STORE_NAME
);
const { getOption, hasFinishedResolution } = select(
OPTIONS_STORE_NAME
);
const {
getActivePlugins,
isJetpackConnected,
isPluginsRequesting,
} = select( PLUGINS_STORE_NAME );
const { getTasksStatus } = select( ONBOARDING_STORE_NAME );
const { general: generalSettings = {} } = getSettings( 'general' );
const countryCode = getCountryCode(
generalSettings.woocommerce_default_country
);
const {
woocommerce_store_address: storeAddress,
woocommerce_default_country: defaultCountry,
woocommerce_store_postcode: storePostCode,
} = generalSettings;
const hasCompleteAddress = Boolean(
storeAddress && defaultCountry && storePostCode
);
const { tax: taxSettings = {} } = getSettings( 'tax' );
const activePlugins = getActivePlugins();
const pluginsToActivate = difference(
[ 'jetpack', 'woocommerce-services' ],
activePlugins
);
const connectOptions = getOption( 'wc_connect_options' ) || {};
const jetpackOptIn = getOption( 'woocommerce_setup_jetpack_opted_in' );
const tosAccepted = connectOptions.tos_accepted || jetpackOptIn === '1';
const tasksStatus = getTasksStatus();
const isPending =
isUpdateSettingsRequesting( 'tax' ) ||
isUpdateSettingsRequesting( 'general' );
const isResolving =
isPluginsRequesting( 'getJetpackConnectUrl' ) ||
! hasFinishedResolution( 'getOption', [
'woocommerce_setup_jetpack_opted_in',
] ) ||
! hasFinishedResolution( 'getOption', [ 'wc_connect_options' ] ) ||
jetpackOptIn === undefined ||
connectOptions === undefined;
return {
countryCode,
generalSettings,
hasCompleteAddress,
isJetpackConnected: isJetpackConnected(),
isPending,
isResolving,
pluginsToActivate,
tasksStatus,
taxSettings,
tosAccepted,
};
} ),
withDispatch( ( dispatch ) => {
const { createNotice } = dispatch( 'core/notices' );
const { updateOptions } = dispatch( OPTIONS_STORE_NAME );
const { updateAndPersistSettingsForGroup } = dispatch(
SETTINGS_STORE_NAME
);
const { invalidateResolutionForStoreSelector } = dispatch(
ONBOARDING_STORE_NAME
);
return {
clearTaskStatusCache: () =>
invalidateResolutionForStoreSelector( 'getTasksStatus' ),
createNotice,
updateAndPersistSettingsForGroup,
updateOptions,
};
} )
)( Tax );
registerPlugin( 'wc-admin-onboarding-task-tax', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="tax">
{ ( { query } ) => <TaxWrapper query={ query } /> }
</WooOnboardingTask>
),
} );

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import React from 'react';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
import { PLUGINS_STORE_NAME } from '@woocommerce/data';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { installActivateAndConnectWcpay } from './PaymentGatewaySuggestions/components/WCPay';
const WoocommercePaymentsTaskItem = () => {
const { installAndActivatePlugins } = useDispatch( PLUGINS_STORE_NAME );
const { createNotice } = useDispatch( 'core/notices' );
return (
<WooOnboardingTaskListItem id="woocommerce-payments">
{ ( { defaultTaskItem: DefaultTaskItem } ) => (
<DefaultTaskItem
onClick={ () => {
return new Promise( ( resolve, reject ) => {
return installActivateAndConnectWcpay(
reject,
createNotice,
installAndActivatePlugins
);
} );
} }
/>
) }
</WooOnboardingTaskListItem>
);
};
registerPlugin( 'woocommerce-admin-task-wcpay', {
scope: 'woocommerce-tasks',
render: WoocommercePaymentsTaskItem,
} );

View File

@ -2,6 +2,7 @@
* Internal dependencies
*/
import { Tasks } from './tasks';
import './fills';
export * from './placeholder';
export default Tasks;

View File

@ -13,9 +13,10 @@ import {
useUserPreferences,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { TaskItem } from '@woocommerce/experimental';
import { TaskItem, useSlot } from '@woocommerce/experimental';
import { useCallback } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
/**
* Internal dependencies
@ -36,6 +37,7 @@ export const TaskListItem: React.FC< TaskListItemProps > = ( {
task,
} ) => {
const { createNotice } = useDispatch( 'core/notices' );
const {
dismissTask,
snoozeTask,
@ -56,6 +58,9 @@ export const TaskListItem: React.FC< TaskListItemProps > = ( {
title,
} = task;
const slot = useSlot( `woocommerce_onboarding_task_list_item_${ id }` );
const hasFills = Boolean( slot?.fills?.length );
const onDismiss = useCallback( () => {
dismissTask();
createNotice( 'success', __( 'Task dismissed' ), {
@ -107,7 +112,7 @@ export const TaskListItem: React.FC< TaskListItemProps > = ( {
} );
};
const onClick = useCallback( () => {
const trackClick = () => {
recordEvent( 'tasklist_click', {
task_name: id,
} );
@ -115,7 +120,9 @@ export const TaskListItem: React.FC< TaskListItemProps > = ( {
if ( ! isComplete ) {
updateTrackStartedCount();
}
};
const onClickDefault = useCallback( () => {
if ( actionUrl ) {
if ( actionUrl.startsWith( 'http' ) ) {
window.location.href = actionUrl;
@ -129,24 +136,56 @@ export const TaskListItem: React.FC< TaskListItemProps > = ( {
updateQueryString( { task: id } );
}, [ id, isComplete, actionUrl ] );
return (
<TaskItem
key={ id }
title={ title }
completed={ isComplete }
content={ content }
onClick={
! isExpandable || isComplete
? onClick
: () => setExpandedTask( id )
}
expandable={ isExpandable }
expanded={ isExpandable && isExpanded }
onDismiss={ isDismissable && onDismiss }
remindMeLater={ isSnoozable && onSnooze }
time={ time }
action={ onClick }
actionLabel={ actionLabel }
const taskItemProps = {
expandable: isExpandable,
expanded: isExpandable && isExpanded,
completed: isComplete,
onSnooze: isSnoozable && onSnooze,
onDismiss: isDismissable && onDismiss,
};
const DefaultTaskItem = useCallback(
( props ) => {
const onClickActions = () => {
trackClick();
if ( props.onClick ) {
return props.onClick();
}
return onClickDefault();
};
return (
<TaskItem
key={ id }
title={ title }
content={ content }
time={ time }
action={ onClickActions }
actionLabel={ actionLabel }
{ ...taskItemProps }
{ ...props }
onClick={
! isExpandable || isComplete
? onClickActions
: () => setExpandedTask( id )
}
/>
);
},
[ id, title, content, time, actionLabel, isExpandable, isComplete ]
);
return hasFills ? (
<WooOnboardingTaskListItem.Slot
id={ id }
fillProps={ {
defaultTaskItem: DefaultTaskItem,
isComplete,
...taskItemProps,
} }
/>
) : (
<DefaultTaskItem />
);
};

View File

@ -1,7 +1,12 @@
/**
* External dependencies
*/
import { WooOnboardingTask } from '@woocommerce/onboarding';
export type TaskProps = {
query: { task: string };
};
export const Task: React.FC< TaskProps > = ( { query } ) => (
<div>Task: { query.task }</div>
<WooOnboardingTask.Slot id={ query.task } fillProps={ { query } } />
);

View File

@ -70,6 +70,11 @@ export const Tasks: React.FC< TasksProps > = ( { query } ) => {
hideTaskList();
};
useEffect( () => {
document.body.classList.add( 'woocommerce-onboarding' );
document.body.classList.add( 'woocommerce-task-dashboard__body' );
}, [] );
useEffect( () => {
// @todo Update this when all task lists have been hidden or completed.
const taskListsFinished = false;
@ -90,7 +95,11 @@ export const Tasks: React.FC< TasksProps > = ( { query } ) => {
}
if ( currentTask ) {
return <Task query={ query } />;
return (
<div className="woocommerce-task-dashboard__container">
<Task query={ query } />
</div>
);
}
if ( isLoadingExperiment ) {

View File

@ -1,5 +1,7 @@
# Unreleased
- Renaming remindMeLater() to onSnooze() for consistency. #7616
# 2.0.3
- Adjust task-item css class to prevent css conflicts. #7593

View File

@ -43,8 +43,8 @@ type TaskItemProps = {
onCollapse?: () => void;
onDelete?: () => void;
onDismiss?: () => void;
onSnooze?: () => void;
onExpand?: () => void;
remindMeLater?: () => void;
additionalInfo?: string;
time?: string;
content: string;
@ -109,8 +109,8 @@ export const TaskItem: React.FC< TaskItemProps > = ( {
onDelete,
onCollapse,
onDismiss,
onSnooze,
onExpand,
remindMeLater,
onClick,
additionalInfo,
time,
@ -139,7 +139,7 @@ export const TaskItem: React.FC< TaskItemProps > = ( {
}
const showEllipsisMenu =
( ( onDismiss || remindMeLater ) && ! completed ) ||
( ( onDismiss || onSnooze ) && ! completed ) ||
( onDelete && completed );
const toggleActionVisibility = () => {
@ -256,11 +256,11 @@ export const TaskItem: React.FC< TaskItemProps > = ( {
{ __( 'Dismiss', 'woocommerce-admin' ) }
</Button>
) }
{ remindMeLater && ! completed && (
{ onSnooze && ! completed && (
<Button
onClick={ ( e: React.MouseEvent ) => {
e.stopPropagation();
remindMeLater();
onSnooze();
} }
>
{ __(

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Slot, Fill } from '@wordpress/components';
export const WooOnboardingTask = ( { id, ...props } ) => (
<Fill name={ 'woocommerce_onboarding_task_' + id } { ...props } />
);
WooOnboardingTask.Slot = ( { id, fillProps } ) => (
<Slot
name={ 'woocommerce_onboarding_task_' + id }
fillProps={ fillProps }
/>
);

View File

@ -0,0 +1 @@
export * from './WooOnboardingTask';

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Slot, Fill } from '@wordpress/components';
export const WooOnboardingTaskListItem = ( { id, ...props } ) => (
<Fill name={ 'woocommerce_onboarding_task_list_item_' + id } { ...props } />
);
WooOnboardingTaskListItem.Slot = ( { id, fillProps } ) => (
<Slot
name={ 'woocommerce_onboarding_task_list_item_' + id }
fillProps={ fillProps }
/>
);

View File

@ -0,0 +1 @@
export * from './WooOnboardingTaskListItem';

View File

@ -10,3 +10,5 @@ export { default as GooglePay } from './images/cards/googlepay';
export { default as WCPayLogo } from './images/wcpay-logo';
export { WooPaymentGatewaySetup } from './components/WooPaymentGatewaySetup';
export { WooPaymentGatewayConfigure } from './components/WooPaymentGatewayConfigure';
export { WooOnboardingTask } from './components/WooOnboardingTask';
export { WooOnboardingTaskListItem } from './components/WooOnboardingTaskListItem';

View File

@ -102,7 +102,9 @@ class TaskLists {
$allowed_product_types = Onboarding::get_allowed_product_types();
$purchaseable_products = array();
$remaining_products = array();
foreach ( $product_types as $product_type ) {
if ( ! isset( $allowed_product_types[ $product_type ]['slug'] ) ) {
continue;
}
@ -113,6 +115,7 @@ class TaskLists {
$remaining_products[] = $allowed_product_types[ $product_type ]['label'];
}
}
$business_extensions = isset( $profiler_data['business_extensions'] ) ? $profiler_data['business_extensions'] : array();
$product_query = new \WC_Product_Query(
array(