Add order attribution install banners (#52661)

* Add order attribution install banner in core analytics overview (#51672)

* Implement order attribution install banner in analytics overview page

* Add a hook for setting banner dismissed option

* Use the hook the decide whether to show banner and call dismiss funtion
when clicking dismiss CTA

* Do not show banner when the plugin is already installed

* Add event tracking for banner viewed and clicked

* UI adjustments based on PR reviews

* Move client/order-attribution to client/order-attribution-install-banner

* Prevent the banner viewed event from being sent again wehn rerendering

* Add a comment to the hook useOrderAttributionInstallBanner

* Remove the prop of eventContext since it uses the default value

* Rename the css class name to make it more general

* Add order attribution install banner in analytics overview's header (#51908)

* Export useOrderAttributionInstallBanner from the first level of the folder

* Add OA install banner in analytics overview page header

* Refactor to use the same component for main and header banners

* Add order attribution install banner in order editor's order attribution metabox (#52282)

* Move OA install banner folder in favour of #49647

* Use slotfill to render OA install banner in order editor's OA metabox

* Don't show badge and image for the banner under OA metabox

* Pass title and description as props

* Pass dismissable as props to decide whether to show the Dismiss button

* Adjust the banner style to make it more customisable

* Show the banner even if the analytics overview banner was dismissed

* Adjust header banner spacing to match the items in the activity panel

* Refactor based on review suggestions

* Add JSDoc and comments and add target=_blank for header banner

* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin

* Add mechanism to promote OA install banner to a percentage of merchants (#52532)

* Add promoting to % of merchants based on WC version

* Add a marketing API to get the misc recommendation from woocommerce.com

* Update the transient expiry to 1 day

The default is 7 days.

* Add actions, reducers, resolvers, selectors for misc recommendations API

* Update logic to decide showing OA banner

* Fix linting and unit tests error

* Update code based on review suggestions

* Fix linting error

* Bump version for order attribution details template

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Ian Yu-Hsun Lin 2024-11-10 18:28:07 +08:00 committed by GitHub
parent ab8b184d02
commit 3b50f76268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 863 additions and 2 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add banners for promoting order attribution plugin (woocommerce-analytics).

View File

@ -2,11 +2,16 @@
* External dependencies
*/
import { Component, Suspense, lazy } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Spinner } from '@woocommerce/components';
/**
* Internal dependencies
*/
import {
OrderAttributionInstallBanner,
OrderAttributionInstallBannerImage,
} from '~/order-attribution-install-banner';
import './style.scss';
const CustomizableDashboard = lazy( () =>
@ -19,6 +24,20 @@ class Dashboard extends Component {
return (
<Suspense fallback={ <Spinner /> }>
<OrderAttributionInstallBanner
title={ __(
'Discover what drives your sales',
'woocommerce'
) }
description={ __(
'Understand what truly drives revenue with our powerful order attribution extension. Use it to track your sales journey, identify your most effective marketing channels, and optimize your sales strategy.',
'woocommerce'
) }
buttonText={ __( 'Try it now', 'woocommerce' ) }
badgeText={ __( 'New', 'woocommerce' ) }
bannerImage={ <OrderAttributionInstallBannerImage /> }
dismissable
/>
<CustomizableDashboard query={ query } path={ path } />
</Suspense>
);

View File

@ -14,7 +14,7 @@ import {
} from '@woocommerce/admin-layout';
import { getSetting } from '@woocommerce/settings';
import { Text, useSlot } from '@woocommerce/experimental';
import { getScreenFromPath, isWCAdmin } from '@woocommerce/navigation';
import { getScreenFromPath, isWCAdmin, getPath } from '@woocommerce/navigation';
/**
* Internal dependencies
@ -26,6 +26,10 @@ import {
LaunchYourStoreStatus,
useLaunchYourStore,
} from '../launch-your-store';
import {
OrderAttributionInstallBanner,
BANNER_TYPE_HEADER as ORDER_ATTRIBUTION_INSTALL_BANNER_TYPE_HEADER,
} from '~/order-attribution-install-banner';
export const PAGE_TITLE_FILTER = 'woocommerce_admin_header_page_title';
@ -128,6 +132,9 @@ export const Header = ( { sections, isEmbedded = false, query } ) => {
const showLaunchYourStoreStatus =
isHomescreen && launchYourStoreEnabled && ! isLoading;
const isAnalyticsOverviewScreen =
isWCAdmin() && getPath() === '/analytics/overview';
return (
<div className={ className } ref={ headerElement }>
{ activeSetupList && (
@ -161,6 +168,14 @@ export const Header = ( { sections, isEmbedded = false, query } ) => {
</Text>
{ showLaunchYourStoreStatus && <LaunchYourStoreStatus /> }
{ isAnalyticsOverviewScreen && (
<OrderAttributionInstallBanner
bannerType={
ORDER_ATTRIBUTION_INSTALL_BANNER_TYPE_HEADER
}
eventContext="analytics-overview-header"
/>
) }
<WooHeaderItem.Slot fillProps={ { isEmbedded, query } } />
</div>

View File

@ -35,6 +35,10 @@ import {
} from './settings-payments';
import { ErrorBoundary } from './error-boundary';
import { registerBlueprintSlotfill } from './blueprint';
import {
possiblyRenderOrderAttributionSlot,
registerOrderAttributionSlotFill,
} from './order-attribution-install-banner/order-editor/slot';
const appRoot = document.getElementById( 'root' );
const embeddedRoot = document.getElementById( 'woocommerce-embedded-root' );
@ -116,6 +120,9 @@ if ( appRoot ) {
if ( window.wcAdminFeatures && window.wcAdminFeatures.blueprint === true ) {
registerBlueprintSlotfill();
}
possiblyRenderOrderAttributionSlot();
registerOrderAttributionSlotFill();
}
// Render the CustomerEffortScoreTracksContainer only if

View File

@ -6,6 +6,7 @@ const TYPES = {
INSTALL_AND_ACTIVATE_RECOMMENDED_PLUGIN:
'INSTALL_AND_ACTIVATE_RECOMMENDED_PLUGIN',
SET_BLOG_POSTS: 'SET_BLOG_POSTS',
SET_MISC_RECOMMENDATIONS: 'SET_MISC_RECOMMENDATIONS',
SET_ERROR: 'SET_ERROR',
};

View File

@ -39,6 +39,13 @@ export function receiveRecommendedPlugins( plugins, category ) {
};
}
export function receiveMiscRecommendations( miscRecommendations ) {
return {
type: TYPES.SET_MISC_RECOMMENDATIONS,
data: { miscRecommendations },
};
}
export function receiveBlogPosts( posts, category ) {
return {
type: TYPES.SET_BLOG_POSTS,

View File

@ -15,6 +15,7 @@ const DEFAULT_STATE = {
installedPlugins: installedExtensions,
activatingPlugins: [],
recommendedPlugins: {},
miscRecommendations: [],
blogPosts: {},
errors: {},
};
@ -72,6 +73,11 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
[ action.data.category ]: action.data.posts,
},
};
case TYPES.SET_MISC_RECOMMENDATIONS:
return {
...state,
miscRecommendations: action.data.miscRecommendations,
};
case TYPES.SET_ERROR:
return {
...state,

View File

@ -10,6 +10,7 @@ import { apiFetch } from '@wordpress/data-controls';
import {
handleFetchError,
receiveRecommendedPlugins,
receiveMiscRecommendations,
receiveBlogPosts,
setError,
} from './actions';
@ -38,6 +39,28 @@ export function* getRecommendedPlugins( category ) {
}
}
export function* getMiscRecommendations() {
try {
const response = yield apiFetch( {
path: `${ API_NAMESPACE }/misc-recommendations`,
} );
if ( response ) {
yield receiveMiscRecommendations( response );
} else {
throw new Error();
}
} catch ( error ) {
yield handleFetchError(
error,
__(
'There was an error loading misc recommendations',
'woocommerce'
)
);
}
}
export function* getBlogPosts( category ) {
try {
const categoryParam = yield category ? `?category=${ category }` : '';

View File

@ -10,6 +10,10 @@ export function getRecommendedPlugins( state, category ) {
return state.recommendedPlugins[ category ] || [];
}
export function getMiscRecommendations( state ) {
return state.miscRecommendations;
}
export function getBlogPosts( state, category ) {
return state.blogPosts[ category ] || [];
}

View File

@ -0,0 +1,9 @@
export const ORDER_ATTRIBUTION_SLOT_FILL_CONSTANT =
'__EXPERIMENTAL__WcAdminOrderAttributionSlots';
export const ORDER_ATTRIBUTION_INSTALL_BANNER_DOM_ID =
'order-attribution-install-banner-slotfill';
export const ORDER_ATTRIBUTION_INSTALL_BANNER_SLOT_SCOPE =
'order-attribution-install-banner';
export const BANNER_TYPE_BIG = 'order-attribution-install-banner-big';
export const BANNER_TYPE_SMALL = 'order-attribution-install-banner-small';
export const BANNER_TYPE_HEADER = 'order-attribution-install-banner-header';

View File

@ -0,0 +1,4 @@
export * from './order-attribution-install-banner';
export * from './order-attribution-install-banner-image';
export * from './use-order-attribution-install-banner';
export * from './constants';

View File

@ -0,0 +1,60 @@
export const OrderAttributionInstallBannerImage = () => (
<svg
width="156"
height="159"
viewBox="0 0 156 159"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="156" height="159" fill="white" />
<rect width="156" height="156" fill="white" />
<g clipPath="url(#clip0_2736_17062)">
<rect
width="142"
height="142"
transform="translate(7 7)"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.0977 114.983C4.18437 87.4207 6.33647 56.3862 24.9045 45.666C34.5831 40.078 46.8115 41.1124 58.5555 47.2872C58.4035 35.8329 62.3202 26.2999 70.1879 21.7576C79.3736 16.4542 91.7579 19.2162 103.017 27.7639C101.298 19.4628 103.233 11.9838 108.644 8.86007C116.335 4.41967 128.136 10.4613 135.002 22.3544C141.869 34.2475 141.2 47.4884 133.509 51.9288C130.491 53.6712 126.841 53.7996 123.103 52.5938C134.128 74.6399 132.448 97.8318 118.694 105.773C112.641 109.267 105.198 109.26 97.6045 106.402C99.5284 123.573 94.3619 138.649 82.5317 145.479C63.9637 156.199 36.011 142.546 20.0977 114.983Z"
fill="#F9F6FF"
/>
<path
d="M128.714 49.0745C131.36 49.0745 133.785 49.7713 136 50.7842V41.1369C136 35.1931 132.613 31.8594 126.634 31.8594H29.3611C23.3874 31.854 20 35.1877 20 41.1315V99.2728H65.7462C63.4879 102.639 61.3157 106.621 61.3157 111.284C61.3157 120.604 68.6067 128.161 77.957 128.161C87.3073 128.161 94.5983 120.604 94.5983 111.284C94.5983 106.621 92.4261 102.639 90.1678 99.2728H136V80.3587C133.785 81.3717 131.36 82.0684 128.714 82.0684C119.412 82.0684 111.869 74.8436 111.869 65.5661C111.869 56.2886 119.412 49.0638 128.714 49.0638V49.0745Z"
fill="#BEA0F2"
/>
<path
d="M68.268 75.1553H29.5547V79.9789H68.268V75.1553Z"
fill="#674399"
/>
<path
d="M53.7505 84.8018H29.5547V89.6254H53.7505V84.8018Z"
fill="#674399"
/>
<path
d="M134.864 107.13C132.799 105.495 112.05 89.6309 112.05 89.6309C112.05 89.6309 109.388 115.561 109.162 118.182C108.937 120.802 111.485 121.955 113.539 120.224C116.394 117.828 122.207 113.111 122.207 113.111C122.207 113.111 129.637 112.131 133.342 111.707C136.014 111.402 136.923 108.765 134.853 107.13H134.864Z"
fill="#674399"
/>
<path
d="M68.268 41.3887H29.5547V70.3307H68.268V41.3887Z"
fill="#674399"
/>
<path
d="M29.5547 70.3307C45.242 58.6073 58.3995 48.9761 68.268 41.3887V70.3307H29.5547Z"
fill="#3C2861"
/>
</g>
<defs>
<clipPath id="clip0_2736_17062">
<rect
width="142"
height="142"
fill="white"
transform="translate(7 7)"
/>
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,192 @@
/**
* External dependencies
*/
import { Button, Card, CardBody } from '@wordpress/components';
import { useEffect, useCallback } from '@wordpress/element';
import { plugins, external } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { Text } from '@woocommerce/experimental';
import { recordEvent } from '@woocommerce/tracks';
import { getPath } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { useOrderAttributionInstallBanner } from './use-order-attribution-install-banner';
import {
BANNER_TYPE_BIG,
BANNER_TYPE_SMALL,
BANNER_TYPE_HEADER,
} from './constants';
import './style.scss';
const WC_ANALYTICS_PRODUCT_URL =
'https://woocommerce.com/products/woocommerce-analytics';
/**
* The banner to prompt users to install the Order Attribution extension.
*
* This banner will only appear when the Order Attribution extension is not installed. It can appear in three different ways:
*
* - As a big banner in the analytics overview page. (dismissable)
* - As a header banner in the analytics overview page. (non-dismissable, appear only when the big banner is dismissed)
* - As a small banner in the order editor. (non-dismissable)
*
* @param {Object} props Component props.
* @param {Object} props.bannerImage The banner image component.
* @param {string} props.bannerType The type of the banner. Can be BANNER_TYPE_BIG, BANNER_TYPE_SMALL, or BANNER_TYPE_HEADER.
* @param {string} props.eventContext The context for the event tracking.
* @param {boolean} props.dismissable Whether the banner is dismissable.
* @param {string} props.badgeText The badge text to display on the banner.
* @param {string} props.title The title of the banner.
* @param {string} props.description The description of the banner.
* @param {string} props.buttonText The text for the button.
*
* @return {JSX.Element} The rendered component.
*
*/
export const OrderAttributionInstallBanner = ( {
bannerImage = null,
bannerType = BANNER_TYPE_BIG,
eventContext = 'analytics-overview',
dismissable = false,
badgeText = '',
title = '',
description = '',
buttonText = '',
} ) => {
const { isDismissed, dismiss, shouldShowBanner } =
useOrderAttributionInstallBanner();
const onButtonClick = () => {
recordEvent( 'order_attribution_install_banner_clicked', {
path: getPath(),
context: eventContext,
} );
};
const getShouldRender = useCallback( () => {
// The header banner should be shown if shouldShowBanner is true and the big banner is dismissed
if ( bannerType === BANNER_TYPE_HEADER ) {
return shouldShowBanner && isDismissed;
}
// The small banner should always be shown if shouldShowBanner is true.
if ( ! dismissable ) {
return shouldShowBanner;
}
// The big banner should be shown if shouldShowBanner is true and the banner is not dismissed.
return shouldShowBanner && ! isDismissed;
}, [ bannerType, shouldShowBanner, isDismissed, dismissable ] );
const shouldRender = getShouldRender();
useEffect( () => {
if ( ! shouldRender ) {
return;
}
recordEvent( 'order_attribution_install_banner_viewed', {
path: getPath(),
context: eventContext,
} );
}, [ eventContext, shouldRender ] );
if ( ! shouldRender ) {
return null;
}
if ( bannerType === BANNER_TYPE_HEADER ) {
return (
<Button
className="woocommerce-order-attribution-install-header-banner"
href={ WC_ANALYTICS_PRODUCT_URL }
variant="secondary"
icon={ plugins }
size="default"
onClick={ onButtonClick }
target="_blank"
>
{ __( 'Try Order Attribution', 'woocommerce' ) }
</Button>
);
}
const isSmallBanner = bannerType === BANNER_TYPE_SMALL;
return (
<Card
size="medium"
className={ `woocommerce-order-attribution-install-banner ${
isSmallBanner ? 'small' : ''
}` }
>
<CardBody
className={ `woocommerce-order-attribution-install-banner__body ${
isSmallBanner ? 'small' : ''
}` }
>
<div className="woocommerce-order-attribution-install-banner__image_container">
{ bannerImage }
</div>
<div
className={ `woocommerce-order-attribution-install-banner__text_container ${
isSmallBanner ? 'small' : ''
}` }
>
{ badgeText && (
<div className="woocommerce-order-attribution-install-banner__text-badge">
<Text
className="woocommerce-order-attribution-install-banner__text-description"
as="p"
size="12"
align="center"
>
{ badgeText }
</Text>
</div>
) }
{ title && (
<Text
className="woocommerce-order-attribution-install-banner__text-title"
as="p"
size="16"
>
{ title }
</Text>
) }
{ description && (
<Text
className="woocommerce-order-attribution-install-banner__text-description"
as="p"
size="12"
>
{ description }
</Text>
) }
<div>
<Button
className={ isSmallBanner ? 'small' : '' }
href={ WC_ANALYTICS_PRODUCT_URL }
variant={ isSmallBanner ? 'secondary' : 'primary' }
onClick={ onButtonClick }
icon={ isSmallBanner ? external : null }
iconPosition={ isSmallBanner ? 'right' : null }
target="_blank"
>
{ buttonText }
</Button>
{ dismissable && (
<Button
variant="tertiary"
onClick={ () => dismiss( eventContext ) }
>
{ __( 'Dismiss', 'woocommerce' ) }
</Button>
) }
</div>
</div>
</CardBody>
</Card>
);
};

View File

@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { createRoot } from '@wordpress/element';
import { createSlotFill, SlotFillProvider } from '@wordpress/components';
import { PluginArea, registerPlugin } from '@wordpress/plugins';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { OrderAttributionInstallBanner } from '../order-attribution-install-banner';
import {
ORDER_ATTRIBUTION_INSTALL_BANNER_DOM_ID,
ORDER_ATTRIBUTION_INSTALL_BANNER_SLOT_SCOPE,
ORDER_ATTRIBUTION_SLOT_FILL_CONSTANT,
BANNER_TYPE_SMALL,
} from '../constants';
const { Slot, Fill } = createSlotFill( ORDER_ATTRIBUTION_SLOT_FILL_CONSTANT );
export const possiblyRenderOrderAttributionSlot = () => {
const slotDomElement = document.getElementById(
ORDER_ATTRIBUTION_INSTALL_BANNER_DOM_ID
);
if ( slotDomElement ) {
createRoot( slotDomElement ).render(
<>
<SlotFillProvider>
<Slot />
<PluginArea
scope={ ORDER_ATTRIBUTION_INSTALL_BANNER_SLOT_SCOPE }
/>
</SlotFillProvider>
</>
);
}
};
const OrderAttributionInstallBannerSlotFill = () => {
return (
<Fill>
<OrderAttributionInstallBanner
eventContext="order-editor-order-attribution-metabox"
bannerType={ BANNER_TYPE_SMALL }
description={ __(
'View all of your orders in our new Order Attribution extension.',
'woocommerce'
) }
buttonText={ __( 'Install the extension', 'woocommerce' ) }
/>
</Fill>
);
};
export const registerOrderAttributionSlotFill = () => {
registerPlugin(
'woocommerce-admin-order-editor-order-attribution-install-banner-slotfill',
{
scope: ORDER_ATTRIBUTION_INSTALL_BANNER_SLOT_SCOPE,
render: OrderAttributionInstallBannerSlotFill,
}
);
};

View File

@ -0,0 +1,106 @@
.woocommerce-order-attribution-install-banner {
margin: 0 15px 10px 0;
animation: isLoaded;
animation-duration: 250ms;
&.components-card {
box-shadow: none;
border: 1px solid $table-border;
border-radius: 8px;
&.small {
border: 0;
border-top: 1px solid #c3c4c7;
border-radius: 0;
margin: 20px -12px -12px;
}
}
.woocommerce-order-attribution-install-banner__body {
display: flex;
align-items: center;
&.small {
padding: 0;
padding-top: 16px;
@media (max-width: $break-small) {
margin-left: 12px;
}
}
@media (max-width: $break-small) {
flex-direction: column;
}
}
.woocommerce-order-attribution-install-banner__image_container {
display: flex;
padding-left: 15px;
}
.woocommerce-order-attribution-install-banner__text_container {
margin-inline: 24px;
> * {
margin-block: 1rem;
}
p {
margin-block: 0.1rem;
}
&.small {
margin-inline: 0;
margin-inline-end: 16px;
}
}
.woocommerce-order-attribution-install-banner__text-badge {
display: inline-flex;
justify-content: center;
background: $studio-gray-0;
border-radius: 2px;
padding: 2px 5px;
p {
color: $studio-gray-80;
}
}
.woocommerce-order-attribution-install-banner__text-title {
color: $studio-gray-90;
line-height: 24px;
font-weight: 600;
}
.woocommerce-order-attribution-install-banner__text-description {
color: $studio-gray-90;
line-height: 16px;
max-width: 580px;
}
}
.components-button.woocommerce-order-attribution-install-header-banner {
margin-right: 12px;
@media (max-width: $break-small) {
text-wrap: wrap;
font-size: 11px;
svg {
display: none;
}
}
}
.components-button {
&.small {
width: 100%;
}
&.has-icon.has-text {
justify-content: center;
}
}

View File

@ -0,0 +1,189 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useMemo } from '@wordpress/element';
import {
OPTIONS_STORE_NAME,
PLUGINS_STORE_NAME,
useUser,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { getPath } from '@woocommerce/navigation';
import { isWcVersion } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { STORE_KEY } from '~/marketing/data/constants';
import '~/marketing/data';
const OPTION_NAME_BANNER_DISMISSED =
'woocommerce_order_attribution_install_banner_dismissed';
const OPTION_VALUE_YES = 'yes';
const OPTION_NAME_REMOTE_VARIANT_ASSIGNMENT =
'woocommerce_remote_variant_assignment';
const getThreshold = ( percentages ) => {
const defaultPercentages = [
[ '9.7', 100 ], // 100%
[ '9.6', 60 ], // 60%
[ '9.5', 10 ], // 10%
];
if ( ! Array.isArray( percentages ) || percentages.length === 0 ) {
percentages = defaultPercentages;
}
// Sort the percentages in descending order by version, to ensure we get the highest version first so the isWcVersion() check works correctly.
// E.g. if we are on 9.7 but the percentages are in version ascending order, we would get 10% instead of 100%.
percentages.sort( ( a, b ) => parseFloat( b[ 0 ] ) - parseFloat( a[ 0 ] ) );
for ( let [ version, percentage ] of percentages ) {
if ( isWcVersion( version, '>=' ) ) {
percentage = parseInt( percentage, 10 );
if ( isNaN( percentage ) ) {
return 12; // Default to 10% if the percentage is not a number.
}
// Since remoteVariantAssignment ranges from 1 to 120, we need to convert the percentage to a number between 1 and 120.
return ( percentage / 100 ) * 120;
}
}
return 12; // Default to 10% if version is lower than 9.5
};
const shouldPromoteOrderAttribution = (
remoteVariantAssignment,
percentages
) => {
remoteVariantAssignment = parseInt( remoteVariantAssignment, 10 );
if ( isNaN( remoteVariantAssignment ) ) {
return false;
}
const threshold = getThreshold( percentages );
return remoteVariantAssignment <= threshold;
};
/**
* A utility hook designed specifically for the order attribution install banner,
* which determines if the banner should be displayed, checks if it has been dismissed, and provides a function to dismiss it.
*/
export const useOrderAttributionInstallBanner = () => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const { currentUserCan } = useUser();
const dismiss = ( eventContext = 'analytics-overview' ) => {
updateOptions( {
[ OPTION_NAME_BANNER_DISMISSED ]: OPTION_VALUE_YES,
} );
recordEvent( 'order_attribution_install_banner_dismissed', {
path: getPath(),
context: eventContext,
} );
};
const { canUserInstallPlugins, orderAttributionInstallState } = useSelect(
( select ) => {
const { getPluginInstallState } = select( PLUGINS_STORE_NAME );
const installState = getPluginInstallState(
'woocommerce-analytics'
);
return {
orderAttributionInstallState: installState,
canUserInstallPlugins: currentUserCan( 'install_plugins' ),
};
},
[ currentUserCan ]
);
const { loading, isBannerDismissed, remoteVariantAssignment } = useSelect(
( select ) => {
const { getOption, hasFinishedResolution } =
select( OPTIONS_STORE_NAME );
return {
loading: ! hasFinishedResolution( 'getOption', [
OPTION_NAME_BANNER_DISMISSED,
] ),
isBannerDismissed: getOption( OPTION_NAME_BANNER_DISMISSED ),
remoteVariantAssignment: getOption(
OPTION_NAME_REMOTE_VARIANT_ASSIGNMENT
),
};
},
[]
);
const { loadingRecommendations, recommendations } = useSelect(
( select ) => {
const { getMiscRecommendations, hasFinishedResolution } =
select( STORE_KEY );
return {
loadingRecommendations: ! hasFinishedResolution(
'getMiscRecommendations'
),
recommendations: getMiscRecommendations(),
};
},
[]
);
const percentages = useMemo( () => {
if (
loadingRecommendations ||
! Array.isArray( recommendations ) ||
recommendations.length === 0
) {
return null;
}
for ( const recommendation of recommendations ) {
if ( recommendation.id === 'woocommerce-analytics' ) {
return (
recommendation?.order_attribution_promotion_percentage ||
null
);
}
}
return null;
}, [ loadingRecommendations, recommendations ] );
const getShouldShowBanner = useCallback( () => {
if ( ! canUserInstallPlugins || loading ) {
return false;
}
const isPluginInstalled = [ 'installed', 'activated' ].includes(
orderAttributionInstallState
);
if ( isPluginInstalled ) {
return false;
}
return shouldPromoteOrderAttribution(
remoteVariantAssignment,
percentages
);
}, [
loading,
canUserInstallPlugins,
orderAttributionInstallState,
remoteVariantAssignment,
percentages,
] );
return {
loading,
isDismissed: isBannerDismissed === OPTION_VALUE_YES,
dismiss,
shouldShowBanner: getShouldShowBanner(),
};
};

View File

@ -79,6 +79,19 @@ class Marketing extends \WC_REST_Data_Controller {
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/misc-recommendations',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_misc_recommendations' ),
'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
@ -141,4 +154,19 @@ class Marketing extends \WC_REST_Data_Controller {
$category = $request->get_param( 'category' );
return rest_ensure_response( $marketing_specs->get_knowledge_base_posts( $category ) );
}
/**
* Return misc recommendations.
*
* @param \WP_REST_Request $request Request data.
*
* @since 9.5.0
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_misc_recommendations( $request ) {
$misc_recommendations = MarketingRecommendationsInit::get_misc_recommendations();
return rest_ensure_response( $misc_recommendations );
}
}

View File

@ -223,6 +223,8 @@ class Options extends \WC_REST_Data_Controller {
'woocommerce_private_link',
'woocommerce_share_key',
'woocommerce_show_lys_tour',
'woocommerce_order_attribution_install_banner_dismissed',
'woocommerce_remote_variant_assignment',
// WC Test helper options.
'wc-admin-test-helper-rest-api-filters',
'wc_admin_helper_feature_values',

View File

@ -37,6 +37,7 @@ class Init extends RemoteSpecsEngine {
*/
public static function delete_specs_transient() {
MarketingRecommendationsDataSourcePoller::get_instance()->delete_specs_transient();
MiscRecommendationsDataSourcePoller::get_instance()->delete_specs_transient();
}
/**
@ -56,6 +57,25 @@ class Init extends RemoteSpecsEngine {
return $specs;
}
/**
* Get misc recommendations specs or fetch remotely if they don't exist.
*
* @since 9.5.0
*/
public static function get_misc_recommendations_specs() {
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return array();
}
$specs = MiscRecommendationsDataSourcePoller::get_instance()->get_specs_from_data_sources();
// Return empty specs if they don't yet exist.
if ( ! is_array( $specs ) ) {
return array();
}
return $specs;
}
/**
* Process specs.
*
@ -140,6 +160,38 @@ class Init extends RemoteSpecsEngine {
);
}
/**
* Load misc recommendations from WooCommerce.com
*
* @since 9.5.0
* @return array
*/
public static function get_misc_recommendations(): array {
$specs = self::get_misc_recommendations_specs();
$results = self::evaluate_specs( $specs );
$specs_to_return = $results['suggestions'];
$specs_to_save = null;
if ( empty( $specs_to_return ) ) {
// When misc_recommendations is empty, replace it with defaults and save for 3 hours.
$specs_to_save = array();
} elseif ( count( $results['errors'] ) > 0 ) {
// When misc_recommendations is not empty but has errors, save it for 3 hours.
$specs_to_save = $specs;
}
if ( $specs_to_save ) {
MiscRecommendationsDataSourcePoller::get_instance()->set_specs_transient( $specs_to_save, 3 * HOUR_IN_SECONDS );
}
$errors = $results['errors'];
if ( ! empty( $errors ) ) {
self::log_errors( $errors );
}
return $specs_to_return;
}
/**
* Returns whether a plugin is a marketing extension.
*

View File

@ -0,0 +1,66 @@
<?php declare(strict_types=1);
namespace Automattic\WooCommerce\Admin\Features\MarketingRecommendations;
use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller;
use WC_Helper;
/**
* Specs data source poller class for misc recommendations.
*
* The misc recommendations are fetched from the WooCommerce.com API, the data structure looks like this:
*
* [
* {
* "id": "woocommerce-analytics",
* "order_attribution_promotion_percentage": [
* [ "9.7", 100 ],
* [ "9.6", 60 ],
* [ "9.5", 10 ]
* ]
* }
* ]
*
* @since 9.5.0
*/
class MiscRecommendationsDataSourcePoller extends DataSourcePoller {
/**
* Data Source Poller ID.
*/
const ID = 'misc_recommendations';
/**
* Class instance.
*
* @var MiscRecommendationsDataSourcePoller instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self(
self::ID,
self::get_data_sources(),
array(
'transient_expiry' => DAY_IN_SECONDS,
)
);
}
return self::$instance;
}
/**
* Get data sources.
*
* @return array
*/
public static function get_data_sources() {
return array(
WC_Helper::get_woocommerce_com_base_url() . 'wp-json/wccom/marketing-tab/misc/recommendations.json',
);
}
}

View File

@ -6,7 +6,7 @@
*
* @see Automattic\WooCommerce\Internal\Orders\OrderAttributionController
* @package WooCommerce\Templates
* @version 9.0.0
* @version 9.5.0
*/
declare( strict_types=1 );
@ -135,4 +135,6 @@ defined( 'ABSPATH' ) || exit;
<?php echo esc_html( $meta['session_pages'] ); ?>
</span>
<?php endif; ?>
<!-- A placeholder for the OA install banner React component. -->
<div id="order-attribution-install-banner-slotfill"></div>
</div>