From 3b50f762689bef390406ecb284d8990d901e1e13 Mon Sep 17 00:00:00 2001 From: Ian Yu-Hsun Lin Date: Sun, 10 Nov 2024 18:28:07 +0800 Subject: [PATCH] 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 --- ...1567-order-attribution-install-and-connect | 4 + .../client/admin/client/dashboard/index.js | 19 ++ .../client/admin/client/header/index.js | 17 +- .../woocommerce/client/admin/client/index.js | 7 + .../client/marketing/data/action-types.js | 1 + .../admin/client/marketing/data/actions.js | 7 + .../admin/client/marketing/data/reducer.js | 6 + .../admin/client/marketing/data/resolvers.js | 23 +++ .../admin/client/marketing/data/selectors.js | 4 + .../constants.js | 9 + .../order-attribution-install-banner/index.js | 4 + .../order-attribution-install-banner-image.js | 60 ++++++ .../order-attribution-install-banner.js | 192 ++++++++++++++++++ .../order-editor/slot.js | 65 ++++++ .../style.scss | 106 ++++++++++ .../use-order-attribution-install-banner.js | 189 +++++++++++++++++ .../woocommerce/src/Admin/API/Marketing.php | 28 +++ plugins/woocommerce/src/Admin/API/Options.php | 2 + .../MarketingRecommendations/Init.php | 52 +++++ .../MiscRecommendationsDataSourcePoller.php | 66 ++++++ .../templates/order/attribution-details.php | 4 +- 21 files changed, 863 insertions(+), 2 deletions(-) create mode 100644 plugins/woocommerce/changelog/52661-feature-51567-order-attribution-install-and-connect create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/constants.js create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/index.js create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner-image.js create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner.js create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-editor/slot.js create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/style.scss create mode 100644 plugins/woocommerce/client/admin/client/order-attribution-install-banner/use-order-attribution-install-banner.js create mode 100644 plugins/woocommerce/src/Admin/Features/MarketingRecommendations/MiscRecommendationsDataSourcePoller.php diff --git a/plugins/woocommerce/changelog/52661-feature-51567-order-attribution-install-and-connect b/plugins/woocommerce/changelog/52661-feature-51567-order-attribution-install-and-connect new file mode 100644 index 00000000000..8a96eefea70 --- /dev/null +++ b/plugins/woocommerce/changelog/52661-feature-51567-order-attribution-install-and-connect @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add banners for promoting order attribution plugin (woocommerce-analytics). \ No newline at end of file diff --git a/plugins/woocommerce/client/admin/client/dashboard/index.js b/plugins/woocommerce/client/admin/client/dashboard/index.js index caf81663f73..3428c00df03 100644 --- a/plugins/woocommerce/client/admin/client/dashboard/index.js +++ b/plugins/woocommerce/client/admin/client/dashboard/index.js @@ -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 ( }> + } + dismissable + /> ); diff --git a/plugins/woocommerce/client/admin/client/header/index.js b/plugins/woocommerce/client/admin/client/header/index.js index d0cfcb088db..74b731ced31 100644 --- a/plugins/woocommerce/client/admin/client/header/index.js +++ b/plugins/woocommerce/client/admin/client/header/index.js @@ -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 (
{ activeSetupList && ( @@ -161,6 +168,14 @@ export const Header = ( { sections, isEmbedded = false, query } ) => { { showLaunchYourStoreStatus && } + { isAnalyticsOverviewScreen && ( + + ) }
diff --git a/plugins/woocommerce/client/admin/client/index.js b/plugins/woocommerce/client/admin/client/index.js index 45b109edf09..e635b2a2dd5 100644 --- a/plugins/woocommerce/client/admin/client/index.js +++ b/plugins/woocommerce/client/admin/client/index.js @@ -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 diff --git a/plugins/woocommerce/client/admin/client/marketing/data/action-types.js b/plugins/woocommerce/client/admin/client/marketing/data/action-types.js index edc1e20fee9..437eea83908 100644 --- a/plugins/woocommerce/client/admin/client/marketing/data/action-types.js +++ b/plugins/woocommerce/client/admin/client/marketing/data/action-types.js @@ -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', }; diff --git a/plugins/woocommerce/client/admin/client/marketing/data/actions.js b/plugins/woocommerce/client/admin/client/marketing/data/actions.js index e3bfd36af41..f60045518f4 100644 --- a/plugins/woocommerce/client/admin/client/marketing/data/actions.js +++ b/plugins/woocommerce/client/admin/client/marketing/data/actions.js @@ -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, diff --git a/plugins/woocommerce/client/admin/client/marketing/data/reducer.js b/plugins/woocommerce/client/admin/client/marketing/data/reducer.js index f27d6b0a302..6fea2ad2c38 100644 --- a/plugins/woocommerce/client/admin/client/marketing/data/reducer.js +++ b/plugins/woocommerce/client/admin/client/marketing/data/reducer.js @@ -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, diff --git a/plugins/woocommerce/client/admin/client/marketing/data/resolvers.js b/plugins/woocommerce/client/admin/client/marketing/data/resolvers.js index f4ee2e8578f..2532cbb91d4 100644 --- a/plugins/woocommerce/client/admin/client/marketing/data/resolvers.js +++ b/plugins/woocommerce/client/admin/client/marketing/data/resolvers.js @@ -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 }` : ''; diff --git a/plugins/woocommerce/client/admin/client/marketing/data/selectors.js b/plugins/woocommerce/client/admin/client/marketing/data/selectors.js index dacf0aa6414..f8e3dca9df7 100644 --- a/plugins/woocommerce/client/admin/client/marketing/data/selectors.js +++ b/plugins/woocommerce/client/admin/client/marketing/data/selectors.js @@ -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 ] || []; } diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/constants.js b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/constants.js new file mode 100644 index 00000000000..ac4edfe027a --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/constants.js @@ -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'; diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/index.js b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/index.js new file mode 100644 index 00000000000..21d1e16cc03 --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/index.js @@ -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'; diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner-image.js b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner-image.js new file mode 100644 index 00000000000..021c43e363a --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner-image.js @@ -0,0 +1,60 @@ +export const OrderAttributionInstallBannerImage = () => ( + + + + + + + + + + + + + + + + + + + +); diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner.js b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner.js new file mode 100644 index 00000000000..9af9b745b9a --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-attribution-install-banner.js @@ -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 ( + + ); + } + + const isSmallBanner = bannerType === BANNER_TYPE_SMALL; + + return ( + + +
+ { bannerImage } +
+
+ { badgeText && ( +
+ + { badgeText } + +
+ ) } + { title && ( + + { title } + + ) } + { description && ( + + { description } + + ) } +
+ + { dismissable && ( + + ) } +
+
+
+
+ ); +}; diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-editor/slot.js b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-editor/slot.js new file mode 100644 index 00000000000..9c3d551ddaf --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/order-editor/slot.js @@ -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( + <> + + + + + + ); + } +}; + +const OrderAttributionInstallBannerSlotFill = () => { + return ( + + + + ); +}; + +export const registerOrderAttributionSlotFill = () => { + registerPlugin( + 'woocommerce-admin-order-editor-order-attribution-install-banner-slotfill', + { + scope: ORDER_ATTRIBUTION_INSTALL_BANNER_SLOT_SCOPE, + render: OrderAttributionInstallBannerSlotFill, + } + ); +}; diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/style.scss b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/style.scss new file mode 100644 index 00000000000..841e19afd7f --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/style.scss @@ -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; + } +} diff --git a/plugins/woocommerce/client/admin/client/order-attribution-install-banner/use-order-attribution-install-banner.js b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/use-order-attribution-install-banner.js new file mode 100644 index 00000000000..a3128c904a1 --- /dev/null +++ b/plugins/woocommerce/client/admin/client/order-attribution-install-banner/use-order-attribution-install-banner.js @@ -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(), + }; +}; diff --git a/plugins/woocommerce/src/Admin/API/Marketing.php b/plugins/woocommerce/src/Admin/API/Marketing.php index dcca5702b42..4a8cacf2fa5 100644 --- a/plugins/woocommerce/src/Admin/API/Marketing.php +++ b/plugins/woocommerce/src/Admin/API/Marketing.php @@ -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 ); + } } diff --git a/plugins/woocommerce/src/Admin/API/Options.php b/plugins/woocommerce/src/Admin/API/Options.php index 836886fec81..4f69eba25b3 100644 --- a/plugins/woocommerce/src/Admin/API/Options.php +++ b/plugins/woocommerce/src/Admin/API/Options.php @@ -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', diff --git a/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/Init.php b/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/Init.php index 8c265023eff..7933f07c9e7 100644 --- a/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/Init.php +++ b/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/Init.php @@ -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. * diff --git a/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/MiscRecommendationsDataSourcePoller.php b/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/MiscRecommendationsDataSourcePoller.php new file mode 100644 index 00000000000..d360c992072 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/MarketingRecommendations/MiscRecommendationsDataSourcePoller.php @@ -0,0 +1,66 @@ + 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', + ); + } +} diff --git a/plugins/woocommerce/templates/order/attribution-details.php b/plugins/woocommerce/templates/order/attribution-details.php index 57dd4e8e601..7b5231f2e8d 100644 --- a/plugins/woocommerce/templates/order/attribution-details.php +++ b/plugins/woocommerce/templates/order/attribution-details.php @@ -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; + +