Add Channels card into Multichannel Marketing page (#36541)
This commit is contained in:
commit
1a06d5bfeb
|
@ -0,0 +1,4 @@
|
|||
.woocommerce-marketing-card-header-description {
|
||||
@include font-size( 14 );
|
||||
color: $gray-700;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './CardHeaderDescription.scss';
|
||||
|
||||
export const CardHeaderDescription: React.FC = ( { children } ) => {
|
||||
return (
|
||||
<div className="woocommerce-marketing-card-header-description">
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { CardHeaderDescription } from './CardHeaderDescription';
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState } from '@wordpress/element';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { Button } from '@wordpress/components';
|
||||
import { Pill } from '@woocommerce/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { PLUGINS_STORE_NAME } from '@woocommerce/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { PluginCardBody } from '~/marketing/components';
|
||||
import { RecommendedPlugin } from '~/marketing/types';
|
||||
import { getInAppPurchaseUrl } from '~/lib/in-app-purchase';
|
||||
import { createNoticesFromResponse } from '~/lib/notices';
|
||||
import { useIsPluginInstalledNotActivated } from './useIsPluginInstalledNotActivated';
|
||||
import './PluginCardBody.scss';
|
||||
|
||||
type SmartPluginCardBodyProps = {
|
||||
plugin: RecommendedPlugin;
|
||||
onInstalledAndActivated?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A smart wrapper around PluginCardBody that accepts a `plugin` prop.
|
||||
*
|
||||
* It knows how to render the action button for the plugin,
|
||||
* and has the logic for installing and activating plugin.
|
||||
* This allows users to install and activate multiple plugins at the same time.
|
||||
*/
|
||||
export const SmartPluginCardBody = ( {
|
||||
plugin,
|
||||
onInstalledAndActivated = () => {},
|
||||
}: SmartPluginCardBodyProps ) => {
|
||||
const [ currentPlugin, setCurrentPlugin ] = useState< string | null >(
|
||||
null
|
||||
);
|
||||
const { installAndActivatePlugins } = useDispatch( PLUGINS_STORE_NAME );
|
||||
const { isPluginInstalledNotActivated } =
|
||||
useIsPluginInstalledNotActivated();
|
||||
|
||||
/**
|
||||
* Install and activate a plugin.
|
||||
*
|
||||
* When the process is successful, `onInstalledAndActivated` will be called.
|
||||
* A success notice will be displayed.
|
||||
*
|
||||
* When the process is not successful, an error notice will be displayed.
|
||||
*/
|
||||
const installAndActivate = async () => {
|
||||
setCurrentPlugin( plugin.product );
|
||||
|
||||
try {
|
||||
recordEvent( 'marketing_recommended_extension', {
|
||||
name: plugin.title,
|
||||
} );
|
||||
|
||||
const response = await installAndActivatePlugins( [
|
||||
plugin.product,
|
||||
] );
|
||||
|
||||
onInstalledAndActivated();
|
||||
createNoticesFromResponse( response );
|
||||
} catch ( error ) {
|
||||
createNoticesFromResponse( error );
|
||||
}
|
||||
|
||||
setCurrentPlugin( null );
|
||||
};
|
||||
|
||||
const renderButton = () => {
|
||||
const buttonDisabled = !! currentPlugin;
|
||||
|
||||
if ( isPluginInstalledNotActivated( plugin.product ) ) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
isBusy={ currentPlugin === plugin.product }
|
||||
disabled={ buttonDisabled }
|
||||
onClick={ installAndActivate }
|
||||
>
|
||||
{ __( 'Activate', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if ( plugin.direct_install ) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
isBusy={ currentPlugin === plugin.product }
|
||||
disabled={ buttonDisabled }
|
||||
onClick={ installAndActivate }
|
||||
>
|
||||
{ __( 'Install plugin', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
href={ getInAppPurchaseUrl( plugin.url ) }
|
||||
disabled={ buttonDisabled }
|
||||
onClick={ () => {
|
||||
recordEvent( 'marketing_recommended_extension', {
|
||||
name: plugin.title,
|
||||
} );
|
||||
} }
|
||||
>
|
||||
{ __( 'View details', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PluginCardBody
|
||||
icon={ <img src={ plugin.icon } alt={ plugin.title } /> }
|
||||
name={ plugin.title }
|
||||
pills={ plugin.tags.map( ( tag ) => (
|
||||
<Pill key={ tag.slug }>{ tag.name }</Pill>
|
||||
) ) }
|
||||
description={ plugin.description }
|
||||
button={ renderButton() }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1 +1,2 @@
|
|||
export { PluginCardBody } from './PluginCardBody';
|
||||
export { SmartPluginCardBody } from './SmartPluginCardBody';
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { PLUGINS_STORE_NAME } from '@woocommerce/data';
|
||||
|
||||
export const useIsPluginInstalledNotActivated = () => {
|
||||
const { getPluginInstallState } = useSelect( ( select ) => {
|
||||
return {
|
||||
getPluginInstallState:
|
||||
select( PLUGINS_STORE_NAME ).getPluginInstallState,
|
||||
};
|
||||
} );
|
||||
|
||||
const isPluginInstalledNotActivated = useCallback(
|
||||
( slug: string ) => {
|
||||
return getPluginInstallState( slug ) === 'installed';
|
||||
},
|
||||
[ getPluginInstallState ]
|
||||
);
|
||||
|
||||
return { isPluginInstalledNotActivated };
|
||||
};
|
|
@ -4,6 +4,7 @@ export { default as ProductIcon } from './product-icon';
|
|||
export { default as Slider } from './slider';
|
||||
export { default as ReadBlogMessage } from './ReadBlogMessage';
|
||||
export { CollapsibleCard, CardBody, CardDivider } from './CollapsibleCard';
|
||||
export { PluginCardBody } from './PluginCardBody';
|
||||
export { PluginCardBody, SmartPluginCardBody } from './PluginCardBody';
|
||||
export { CardHeaderTitle } from './CardHeaderTitle';
|
||||
export { CardHeaderDescription } from './CardHeaderDescription';
|
||||
export { CenteredSpinner } from './CenteredSpinner';
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export const TYPES = {
|
||||
RECEIVE_REGISTERED_CHANNELS_SUCCESS:
|
||||
'RECEIVE_REGISTERED_CHANNELS_SUCCESS' as const,
|
||||
RECEIVE_REGISTERED_CHANNELS_ERROR:
|
||||
'RECEIVE_REGISTERED_CHANNELS_ERROR' as const,
|
||||
RECEIVE_RECOMMENDED_CHANNELS_SUCCESS:
|
||||
'RECEIVE_RECOMMENDED_CHANNELS_SUCCESS' as const,
|
||||
RECEIVE_RECOMMENDED_CHANNELS_ERROR:
|
||||
'RECEIVE_RECOMMENDED_CHANNELS_ERROR' as const,
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TYPES } from './action-types';
|
||||
import { ApiFetchError, RegisteredChannel, RecommendedChannel } from './types';
|
||||
|
||||
export const receiveRegisteredChannelsSuccess = (
|
||||
channels: Array< RegisteredChannel >
|
||||
) => {
|
||||
return {
|
||||
type: TYPES.RECEIVE_REGISTERED_CHANNELS_SUCCESS,
|
||||
payload: channels,
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveRegisteredChannelsError = ( error: ApiFetchError ) => {
|
||||
return {
|
||||
type: TYPES.RECEIVE_REGISTERED_CHANNELS_ERROR,
|
||||
payload: error,
|
||||
error: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveRecommendedChannelsSuccess = (
|
||||
channels: Array< RecommendedChannel >
|
||||
) => {
|
||||
return {
|
||||
type: TYPES.RECEIVE_RECOMMENDED_CHANNELS_SUCCESS,
|
||||
payload: channels,
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveRecommendedChannelsError = ( error: ApiFetchError ) => {
|
||||
return {
|
||||
type: TYPES.RECEIVE_RECOMMENDED_CHANNELS_ERROR,
|
||||
payload: error,
|
||||
error: true,
|
||||
};
|
||||
};
|
||||
|
||||
export type Action = ReturnType<
|
||||
| typeof receiveRegisteredChannelsSuccess
|
||||
| typeof receiveRegisteredChannelsError
|
||||
| typeof receiveRecommendedChannelsSuccess
|
||||
| typeof receiveRecommendedChannelsError
|
||||
>;
|
|
@ -0,0 +1,2 @@
|
|||
export const STORE_KEY = 'wc/marketing-multichannel';
|
||||
export const API_NAMESPACE = '/wc-admin/marketing';
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ApiFetchError } from './types';
|
||||
|
||||
export const isObject = ( obj: unknown ): obj is Record< string, unknown > => {
|
||||
return !! obj && typeof obj === 'object';
|
||||
};
|
||||
|
||||
export const isApiFetchError = ( obj: unknown ): obj is ApiFetchError => {
|
||||
return (
|
||||
isObject( obj ) && 'code' in obj && 'data' in obj && 'message' in obj
|
||||
);
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createReduxStore, register } from '@wordpress/data';
|
||||
import { Reducer, AnyAction } from 'redux';
|
||||
import { controls } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { State } from './types';
|
||||
import { STORE_KEY } from './constants';
|
||||
import { reducer } from './reducer';
|
||||
import * as actions from './actions';
|
||||
import * as selectors from './selectors';
|
||||
import * as resolvers from './resolvers';
|
||||
|
||||
const store = createReduxStore( STORE_KEY, {
|
||||
reducer: reducer as Reducer< State, AnyAction >,
|
||||
actions,
|
||||
selectors,
|
||||
resolvers,
|
||||
controls,
|
||||
} );
|
||||
|
||||
register( store );
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { State } from './types';
|
||||
import { Action } from './actions';
|
||||
import { TYPES } from './action-types';
|
||||
|
||||
const initialState = {
|
||||
registeredChannels: {
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
},
|
||||
recommendedChannels: {
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const reducer: Reducer< State, Action > = (
|
||||
state = initialState,
|
||||
action
|
||||
) => {
|
||||
switch ( action.type ) {
|
||||
case TYPES.RECEIVE_REGISTERED_CHANNELS_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
registeredChannels: {
|
||||
data: action.payload,
|
||||
},
|
||||
};
|
||||
case TYPES.RECEIVE_REGISTERED_CHANNELS_ERROR:
|
||||
return {
|
||||
...state,
|
||||
registeredChannels: {
|
||||
error: action.payload,
|
||||
},
|
||||
};
|
||||
case TYPES.RECEIVE_RECOMMENDED_CHANNELS_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
recommendedChannels: {
|
||||
data: action.payload,
|
||||
},
|
||||
};
|
||||
case TYPES.RECEIVE_RECOMMENDED_CHANNELS_ERROR:
|
||||
return {
|
||||
...state,
|
||||
recommendedChannels: {
|
||||
error: action.payload,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { apiFetch } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
receiveRegisteredChannelsSuccess,
|
||||
receiveRegisteredChannelsError,
|
||||
receiveRecommendedChannelsSuccess,
|
||||
receiveRecommendedChannelsError,
|
||||
} from './actions';
|
||||
import { RegisteredChannel, RecommendedChannel } from './types';
|
||||
import { API_NAMESPACE } from './constants';
|
||||
import { isApiFetchError } from './guards';
|
||||
|
||||
export function* getRegisteredChannels() {
|
||||
try {
|
||||
const data: RegisteredChannel[] = yield apiFetch( {
|
||||
path: `${ API_NAMESPACE }/channels`,
|
||||
} );
|
||||
|
||||
yield receiveRegisteredChannelsSuccess( data );
|
||||
} catch ( error ) {
|
||||
if ( isApiFetchError( error ) ) {
|
||||
yield receiveRegisteredChannelsError( error );
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function* getRecommendedChannels() {
|
||||
try {
|
||||
const data: RecommendedChannel[] = yield apiFetch( {
|
||||
path: `${ API_NAMESPACE }/recommendations?category=channels`,
|
||||
} );
|
||||
|
||||
yield receiveRecommendedChannelsSuccess( data );
|
||||
} catch ( error ) {
|
||||
if ( isApiFetchError( error ) ) {
|
||||
yield receiveRecommendedChannelsError( error );
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { State } from './types';
|
||||
|
||||
export const getRegisteredChannels = ( state: State ) => {
|
||||
return state.registeredChannels;
|
||||
};
|
||||
|
||||
export const getRecommendedChannels = ( state: State ) => {
|
||||
return state.recommendedChannels;
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
export type ApiFetchError = {
|
||||
code: string;
|
||||
data: {
|
||||
status: number;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RegisteredChannel = {
|
||||
slug: string;
|
||||
is_setup_completed: boolean;
|
||||
settings_url: string;
|
||||
name: string;
|
||||
description: string;
|
||||
product_listings_status: string;
|
||||
errors_count: number;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export type RegisteredChannelsState = {
|
||||
data?: Array< RegisteredChannel >;
|
||||
error?: ApiFetchError;
|
||||
};
|
||||
|
||||
type Subcategory = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Tag = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type RecommendedChannel = {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
direct_install: boolean;
|
||||
icon: string;
|
||||
product: string;
|
||||
plugin: string;
|
||||
categories: Array< string >;
|
||||
subcategories: Array< Subcategory >;
|
||||
tags: Array< Tag >;
|
||||
};
|
||||
|
||||
export type RecommendedChannelsState = {
|
||||
data?: Array< RecommendedChannel >;
|
||||
error?: ApiFetchError;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
registeredChannels: RegisteredChannelsState;
|
||||
recommendedChannels: RecommendedChannelsState;
|
||||
};
|
|
@ -1 +1,3 @@
|
|||
export { useInstalledPlugins } from './useInstalledPlugins';
|
||||
export { useRegisteredChannels } from './useRegisteredChannels';
|
||||
export { useRecommendedChannels } from './useRecommendedChannels';
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { PLUGINS_STORE_NAME } from '@woocommerce/data';
|
||||
import { differenceWith } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from '~/marketing/data-multichannel/constants';
|
||||
import {
|
||||
RecommendedChannelsState,
|
||||
RecommendedChannel,
|
||||
} from '~/marketing/data-multichannel/types';
|
||||
|
||||
type UseRecommendedChannels = {
|
||||
loading: boolean;
|
||||
data?: Array< RecommendedChannel >;
|
||||
};
|
||||
|
||||
export const useRecommendedChannels = (): UseRecommendedChannels => {
|
||||
return useSelect( ( select ) => {
|
||||
const { hasFinishedResolution, getRecommendedChannels } =
|
||||
select( STORE_KEY );
|
||||
const { data, error } =
|
||||
getRecommendedChannels< RecommendedChannelsState >();
|
||||
|
||||
const { getActivePlugins } = select( PLUGINS_STORE_NAME );
|
||||
const activePlugins = getActivePlugins();
|
||||
|
||||
/**
|
||||
* Recommended channels that are not in "active" state,
|
||||
* i.e. channels that are not installed or not activated yet.
|
||||
*/
|
||||
const nonActiveRecommendedChannels =
|
||||
data &&
|
||||
differenceWith( data, activePlugins, ( a, b ) => {
|
||||
return a.product === b;
|
||||
} );
|
||||
|
||||
return {
|
||||
loading: ! hasFinishedResolution( 'getRecommendedChannels' ),
|
||||
data: nonActiveRecommendedChannels,
|
||||
error,
|
||||
};
|
||||
} );
|
||||
};
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { RegisteredChannel, SyncStatusType } from '~/marketing/types';
|
||||
import { STORE_KEY } from '~/marketing/data-multichannel/constants';
|
||||
import {
|
||||
ApiFetchError,
|
||||
RegisteredChannel as APIRegisteredChannel,
|
||||
RegisteredChannelsState,
|
||||
} from '~/marketing/data-multichannel/types';
|
||||
|
||||
type UseRegisteredChannels = {
|
||||
loading: boolean;
|
||||
data?: Array< RegisteredChannel >;
|
||||
error?: ApiFetchError;
|
||||
refetch: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A object that maps the product listings status in
|
||||
* plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php backend
|
||||
* to SyncStatusType frontend.
|
||||
*/
|
||||
const statusMap: Record< string, SyncStatusType > = {
|
||||
'sync-in-progress': 'syncing',
|
||||
'sync-failed': 'failed',
|
||||
synced: 'synced',
|
||||
};
|
||||
|
||||
const convert = ( data: APIRegisteredChannel ): RegisteredChannel => {
|
||||
const issueType = data.errors_count >= 1 ? 'error' : 'none';
|
||||
const issueText =
|
||||
data.errors_count >= 1
|
||||
? sprintf(
|
||||
// translators: %d: The number of issues to resolve.
|
||||
__( '%d issues to resolve', 'woocommerce' ),
|
||||
data.errors_count
|
||||
)
|
||||
: __( 'No issues to resolve', 'woocommerce' );
|
||||
|
||||
return {
|
||||
slug: data.slug,
|
||||
title: data.name,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
isSetupCompleted: data.is_setup_completed,
|
||||
setupUrl: data.settings_url,
|
||||
manageUrl: data.settings_url,
|
||||
syncStatus: statusMap[ data.product_listings_status ],
|
||||
issueType,
|
||||
issueText,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRegisteredChannels = (): UseRegisteredChannels => {
|
||||
const { invalidateResolution } = useDispatch( STORE_KEY );
|
||||
|
||||
const refetch = useCallback( () => {
|
||||
invalidateResolution( 'getRegisteredChannels' );
|
||||
}, [ invalidateResolution ] );
|
||||
|
||||
return useSelect( ( select ) => {
|
||||
const { hasFinishedResolution, getRegisteredChannels } =
|
||||
select( STORE_KEY );
|
||||
const state = getRegisteredChannels< RegisteredChannelsState >();
|
||||
|
||||
return {
|
||||
loading: ! hasFinishedResolution( 'getRegisteredChannels' ),
|
||||
data: state.data?.map( convert ),
|
||||
error: state.error,
|
||||
refetch,
|
||||
};
|
||||
} );
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
.woocommerce-marketing-channels-card {
|
||||
.components-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $gap-smallest;
|
||||
}
|
||||
|
||||
.components-button.is-link {
|
||||
@include font-size( 14 );
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Fragment, useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardDivider,
|
||||
Button,
|
||||
Icon,
|
||||
} from '@wordpress/components';
|
||||
import { chevronUp, chevronDown } from '@wordpress/icons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { RecommendedChannel } from '~/marketing/data-multichannel/types';
|
||||
import {
|
||||
CardHeaderTitle,
|
||||
CardHeaderDescription,
|
||||
SmartPluginCardBody,
|
||||
} from '~/marketing/components';
|
||||
import { RegisteredChannel } from '~/marketing/types';
|
||||
import { RegisteredChannelCardBody } from './RegisteredChannelCardBody';
|
||||
import './Channels.scss';
|
||||
|
||||
type ChannelsProps = {
|
||||
registeredChannels: Array< RegisteredChannel >;
|
||||
recommendedChannels: Array< RecommendedChannel >;
|
||||
onInstalledAndActivated?: () => void;
|
||||
};
|
||||
|
||||
export const Channels: React.FC< ChannelsProps > = ( {
|
||||
registeredChannels,
|
||||
recommendedChannels,
|
||||
onInstalledAndActivated,
|
||||
} ) => {
|
||||
const hasRegisteredChannels = registeredChannels.length >= 1;
|
||||
|
||||
/**
|
||||
* State to collapse / expand the recommended channels.
|
||||
* Initial state is expanded if there are no registered channels in first page load.
|
||||
*/
|
||||
const [ expanded, setExpanded ] = useState( ! hasRegisteredChannels );
|
||||
|
||||
return (
|
||||
<Card className="woocommerce-marketing-channels-card">
|
||||
<CardHeader>
|
||||
<CardHeaderTitle>
|
||||
{ __( 'Channels', 'woocommerce' ) }
|
||||
</CardHeaderTitle>
|
||||
{ ! hasRegisteredChannels && (
|
||||
<CardHeaderDescription>
|
||||
{ __(
|
||||
'Start by adding a channel to your store',
|
||||
'woocommerce'
|
||||
) }
|
||||
</CardHeaderDescription>
|
||||
) }
|
||||
</CardHeader>
|
||||
|
||||
{ /* Registered channels section. */ }
|
||||
{ registeredChannels.map( ( el, idx ) => {
|
||||
return (
|
||||
<Fragment key={ el.slug }>
|
||||
<RegisteredChannelCardBody registeredChannel={ el } />
|
||||
{ idx !== registeredChannels.length - 1 && (
|
||||
<CardDivider />
|
||||
) }
|
||||
</Fragment>
|
||||
);
|
||||
} ) }
|
||||
|
||||
{ /* Recommended channels section. */ }
|
||||
{ recommendedChannels.length >= 1 && (
|
||||
<div>
|
||||
{ hasRegisteredChannels && (
|
||||
<>
|
||||
<CardDivider />
|
||||
<CardBody>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={ () => setExpanded( ! expanded ) }
|
||||
>
|
||||
{ __( 'Add channels', 'woocommerce' ) }
|
||||
<Icon
|
||||
icon={
|
||||
expanded ? chevronUp : chevronDown
|
||||
}
|
||||
size={ 24 }
|
||||
/>
|
||||
</Button>
|
||||
</CardBody>
|
||||
</>
|
||||
) }
|
||||
{ expanded &&
|
||||
recommendedChannels.map( ( el, idx ) => {
|
||||
return (
|
||||
<Fragment key={ el.plugin }>
|
||||
<SmartPluginCardBody
|
||||
plugin={ el }
|
||||
onInstalledAndActivated={
|
||||
onInstalledAndActivated
|
||||
}
|
||||
/>
|
||||
{ idx !==
|
||||
recommendedChannels.length - 1 && (
|
||||
<CardDivider />
|
||||
) }
|
||||
</Fragment>
|
||||
);
|
||||
} ) }
|
||||
</div>
|
||||
) }
|
||||
</Card>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
.woocommerce-marketing-issue-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap-smallest;
|
||||
|
||||
&__error {
|
||||
color: $alert-red;
|
||||
fill: $alert-red;
|
||||
}
|
||||
|
||||
&__warning {
|
||||
color: $alert-yellow;
|
||||
fill: $alert-yellow;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import GridiconNotice from 'gridicons/dist/notice';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { RegisteredChannel } from '~/marketing/types';
|
||||
import { iconSize } from './iconSize';
|
||||
import './IssueStatus.scss';
|
||||
|
||||
type IssueStatusPropsType = {
|
||||
registeredChannel: RegisteredChannel;
|
||||
};
|
||||
|
||||
const issueStatusClassName = 'woocommerce-marketing-issue-status';
|
||||
|
||||
export const IssueStatus: React.FC< IssueStatusPropsType > = ( {
|
||||
registeredChannel,
|
||||
} ) => {
|
||||
if ( registeredChannel.issueType === 'error' ) {
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
issueStatusClassName,
|
||||
`${ issueStatusClassName }__error`
|
||||
) }
|
||||
>
|
||||
<GridiconNotice size={ iconSize } />
|
||||
{ registeredChannel.issueText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ( registeredChannel.issueType === 'warning' ) {
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
issueStatusClassName,
|
||||
`${ issueStatusClassName }__warning`
|
||||
) }
|
||||
>
|
||||
<GridiconNotice size={ iconSize } />
|
||||
{ registeredChannel.issueText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ issueStatusClassName }>
|
||||
{ registeredChannel.issueText }
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
.woocommerce-marketing-registered-channel-card-body {
|
||||
.woocommerce-marketing-registered-channel-description {
|
||||
display: flex;
|
||||
gap: $gap-smaller;
|
||||
|
||||
&__separator::before {
|
||||
content: '•';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Button } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { PluginCardBody } from '~/marketing/components';
|
||||
import { RegisteredChannel } from '~/marketing/types';
|
||||
import { SyncStatus } from './SyncStatus';
|
||||
import { IssueStatus } from './IssueStatus';
|
||||
import './RegisteredChannelCardBody.scss';
|
||||
|
||||
type RegisteredChannelCardBodyProps = {
|
||||
registeredChannel: RegisteredChannel;
|
||||
};
|
||||
|
||||
export const RegisteredChannelCardBody: React.FC<
|
||||
RegisteredChannelCardBodyProps
|
||||
> = ( { registeredChannel } ) => {
|
||||
/**
|
||||
* The description section in the channel card.
|
||||
*
|
||||
* If setup is not completed, this would be the channel description.
|
||||
*
|
||||
* If setup is completed, this would be an element with sync status and issue status.
|
||||
*/
|
||||
const description = ! registeredChannel.isSetupCompleted ? (
|
||||
registeredChannel.description
|
||||
) : (
|
||||
<div className="woocommerce-marketing-registered-channel-description">
|
||||
{ registeredChannel.syncStatus && (
|
||||
<>
|
||||
<SyncStatus status={ registeredChannel.syncStatus } />
|
||||
<div className="woocommerce-marketing-registered-channel-description__separator" />
|
||||
</>
|
||||
) }
|
||||
<IssueStatus registeredChannel={ registeredChannel } />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The action button in the channel card.
|
||||
*
|
||||
* If setup is not completed, this would be a "Finish setup" primary button.
|
||||
*
|
||||
* If setup is completed, this would be a "Manage" secondary button.
|
||||
*/
|
||||
const button = ! registeredChannel.isSetupCompleted ? (
|
||||
<Button variant="primary" href={ registeredChannel.setupUrl }>
|
||||
{ __( 'Finish setup', 'woocommerce' ) }
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" href={ registeredChannel.manageUrl }>
|
||||
{ __( 'Manage', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<PluginCardBody
|
||||
className="woocommerce-marketing-registered-channel-card-body"
|
||||
icon={
|
||||
<img
|
||||
src={ registeredChannel.icon }
|
||||
alt={ registeredChannel.title }
|
||||
/>
|
||||
}
|
||||
name={ registeredChannel.title }
|
||||
description={ description }
|
||||
button={ button }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
.woocommerce-marketing-sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap-smallest;
|
||||
|
||||
&__failed {
|
||||
color: $alert-red;
|
||||
fill: $alert-red;
|
||||
}
|
||||
|
||||
&__syncing,
|
||||
&__synced {
|
||||
color: $studio-green-50;
|
||||
fill: $studio-green-50;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import GridiconCheckmarkCircle from 'gridicons/dist/checkmark-circle';
|
||||
import GridiconSync from 'gridicons/dist/sync';
|
||||
import GridiconNotice from 'gridicons/dist/notice';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { SyncStatusType } from '~/marketing/types';
|
||||
import { iconSize } from './iconSize';
|
||||
import './SyncStatus.scss';
|
||||
|
||||
type SyncStatusPropsType = {
|
||||
status: SyncStatusType;
|
||||
};
|
||||
|
||||
const className = 'woocommerce-marketing-sync-status';
|
||||
|
||||
export const SyncStatus: React.FC< SyncStatusPropsType > = ( { status } ) => {
|
||||
if ( status === 'failed' ) {
|
||||
return (
|
||||
<div
|
||||
className={ classnames( className, `${ className }__failed` ) }
|
||||
>
|
||||
<GridiconNotice size={ iconSize } />
|
||||
{ __( 'Sync failed', 'woocommerce' ) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ( status === 'syncing' ) {
|
||||
return (
|
||||
<div
|
||||
className={ classnames( className, `${ className }__syncing` ) }
|
||||
>
|
||||
<GridiconSync size={ iconSize } />
|
||||
{ __( 'Syncing', 'woocommerce' ) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ classnames( className, `${ className }__synced` ) }>
|
||||
<GridiconCheckmarkCircle size={ iconSize } />
|
||||
{ __( 'Synced', 'woocommerce' ) }
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export const iconSize = 18;
|
|
@ -0,0 +1 @@
|
|||
export { Channels } from './Channels';
|
|
@ -6,22 +6,52 @@ import { useUser } from '@woocommerce/data';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import '~/marketing/data';
|
||||
import '~/marketing/data-multichannel';
|
||||
import { CenteredSpinner } from '~/marketing/components';
|
||||
import {
|
||||
useRegisteredChannels,
|
||||
useRecommendedChannels,
|
||||
} from '~/marketing/hooks';
|
||||
import { getAdminSetting } from '~/utils/admin-settings';
|
||||
import { Channels } from './Channels';
|
||||
import { InstalledExtensions } from './InstalledExtensions';
|
||||
import { DiscoverTools } from './DiscoverTools';
|
||||
import { LearnMarketing } from './LearnMarketing';
|
||||
import '~/marketing/data';
|
||||
import './MarketingOverviewMultichannel.scss';
|
||||
|
||||
export const MarketingOverviewMultichannel: React.FC = () => {
|
||||
const {
|
||||
loading: loadingRegistered,
|
||||
data: dataRegistered,
|
||||
refetch,
|
||||
} = useRegisteredChannels();
|
||||
const { loading: loadingRecommended, data: dataRecommended } =
|
||||
useRecommendedChannels();
|
||||
const { currentUserCan } = useUser();
|
||||
|
||||
const shouldShowExtensions =
|
||||
getAdminSetting( 'allowMarketplaceSuggestions', false ) &&
|
||||
currentUserCan( 'install_plugins' );
|
||||
|
||||
if (
|
||||
( loadingRegistered && ! dataRegistered ) ||
|
||||
( loadingRecommended && ! dataRecommended )
|
||||
) {
|
||||
return <CenteredSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="woocommerce-marketing-overview-multichannel">
|
||||
{ dataRegistered &&
|
||||
dataRecommended &&
|
||||
( dataRegistered.length || dataRecommended.length ) && (
|
||||
<Channels
|
||||
registeredChannels={ dataRegistered }
|
||||
recommendedChannels={ dataRecommended }
|
||||
onInstalledAndActivated={ refetch }
|
||||
/>
|
||||
) }
|
||||
<InstalledExtensions />
|
||||
{ shouldShowExtensions && <DiscoverTools /> }
|
||||
<LearnMarketing />
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
export type SyncStatusType = 'synced' | 'syncing' | 'failed';
|
||||
export type IssueTypeType = 'error' | 'warning' | 'none';
|
||||
|
||||
export type RegisteredChannel = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
isSetupCompleted: boolean;
|
||||
setupUrl: string;
|
||||
manageUrl: string;
|
||||
syncStatus: SyncStatusType;
|
||||
issueType: IssueTypeType;
|
||||
issueText: string;
|
||||
};
|
|
@ -1,2 +1,7 @@
|
|||
export { InstalledPlugin } from './InstalledPlugin';
|
||||
export { RecommendedPlugin } from './RecommendedPlugin';
|
||||
export {
|
||||
SyncStatusType,
|
||||
IssueTypeType,
|
||||
RegisteredChannel,
|
||||
} from './RegisteredChannel';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add a new Channels card in multichannel marketing page.
|
Loading…
Reference in New Issue