diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-off.svg b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-off.svg new file mode 100644 index 0000000000000..e1f7f232bc396 --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-partial.svg b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-partial.svg new file mode 100644 index 0000000000000..03b09674fdade --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-partial.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-success.svg b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-success.svg new file mode 100644 index 0000000000000..0f3ebeca1048d --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/assets/shield-success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/index.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/index.tsx index 4d6a3a741cdf5..5f6b6cd236b97 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/index.tsx +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/index.tsx @@ -1,20 +1,50 @@ import { __ } from '@wordpress/i18n'; -import { PRODUCT_STATUSES } from '../../../constants'; +import { useCallback, type FC } from 'react'; +import useProduct from '../../../data/products/use-product'; +import useAnalytics from '../../../hooks/use-analytics'; import ProductCard from '../../connected-product-card'; import ProtectValueSection from './protect-value-section'; -import type { FC } from 'react'; const ProtectCard: FC< { admin: boolean } > = ( { admin } ) => { - // Override the primary action button to read "Protect your site" instead - // of the default text, "Learn more". - const primaryActionOverride = { - [ PRODUCT_STATUSES.ABSENT ]: { - label: __( 'Protect your site', 'jetpack-my-jetpack' ), - }, + const { recordEvent } = useAnalytics(); + const slug = 'protect'; + const { detail } = useProduct( slug ); + const { hasPaidPlanForProduct: hasProtectPaidPlan } = detail; + + /** + * Called when secondary "View" button is clicked. + */ + const onViewButtonClick = useCallback( () => { + recordEvent( 'jetpack_myjetpack_product_card_manage_click', { + product: slug, + } ); + }, [ recordEvent ] ); + + const shouldShowSecondaryButton = useCallback( + () => ! hasProtectPaidPlan, + [ hasProtectPaidPlan ] + ); + + const viewButton = { + href: 'admin.php?page=jetpack-protect', + label: __( 'View', 'jetpack-my-jetpack' ), + onClick: onViewButtonClick, + shouldShowButton: shouldShowSecondaryButton, }; + // This is a workaround to remove the Description from the product card. However if we end + // up needing to remove the Description from additional cards in the future, we might consider + // extending functionality to support that. + const noDescription = useCallback( () => null, [] ); + return ( - + ); diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/info-tooltip/index.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/info-tooltip/index.tsx new file mode 100644 index 0000000000000..de355f68cf165 --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/info-tooltip/index.tsx @@ -0,0 +1,71 @@ +import { Gridicon } from '@automattic/jetpack-components'; +import { Popover } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { useState, useCallback, useRef } from 'react'; +import useAnalytics from '../../../../hooks/use-analytics'; +import type { FC, ReactNode } from 'react'; + +import './style.scss'; + +type Props = { + children: ReactNode; + icon?: string; + iconSize?: number; + tracksEventName?: string; + tracksEventProps?: { [ key: string ]: string | boolean | number }; +}; + +export const InfoTooltip: FC< Props > = ( { + children, + icon = 'info-outline', + iconSize = 14, + tracksEventName, + tracksEventProps = {}, +} ) => { + const { recordEvent } = useAnalytics(); + const useTooltipRef = useRef< HTMLButtonElement >(); + const isMobileViewport: boolean = useViewportMatch( 'medium', '<' ); + const [ isPopoverVisible, setIsPopoverVisible ] = useState( false ); + + const toggleTooltip = useCallback( + () => + setIsPopoverVisible( prevState => { + if ( ! prevState === true && tracksEventName ) { + recordEvent( `jetpack_${ tracksEventName }`, { + page: 'my-jetpack', + feature: 'jetpack-protect', + ...tracksEventProps, + } ); + } + return ! prevState; + } ), + [ recordEvent, tracksEventName, tracksEventProps ] + ); + + const hideTooltip = useCallback( () => { + // Don't hide the tooltip here if it's the tooltip button that was clicked (the button + // becoming the document's activeElement). Instead let toggleTooltip() handle the closing. + if ( useTooltipRef.current && ! useTooltipRef.current.contains( document.activeElement ) ) { + setIsPopoverVisible( false ); + } + }, [ setIsPopoverVisible, useTooltipRef ] ); + + return ( + + + { isPopoverVisible && ( + + { children } + + ) } + + ); +}; diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/info-tooltip/style.scss b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/info-tooltip/style.scss new file mode 100644 index 0000000000000..a39f6451f89ff --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/info-tooltip/style.scss @@ -0,0 +1,12 @@ +.info-tooltip__button { + display: flex; + align-items: center; + background: transparent; + border: none; + color: var(--jp-gray-50); + padding: 2px; + cursor: pointer; + svg { + margin: 0 auto; + } +} diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/protect-value-section.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/protect-value-section.tsx index 4e6600aae9fa1..5d71e2d9993a6 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/protect-value-section.tsx +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/protect-value-section.tsx @@ -1,11 +1,10 @@ -import { Gridicon } from '@automattic/jetpack-components'; -import { Popover } from '@wordpress/components'; -import { useViewportMatch } from '@wordpress/compose'; -import { __, sprintf } from '@wordpress/i18n'; -import { useState, useCallback, useMemo, useRef } from 'react'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useMemo } from 'react'; import useProduct from '../../../data/products/use-product'; import { getMyJetpackWindowInitialState } from '../../../data/utils/get-my-jetpack-window-state'; import { timeSince } from '../../../utils/time-since'; +import { InfoTooltip } from './info-tooltip'; +import { ScanAndThreatStatus } from './scan-threats'; import { useProtectTooltipCopy } from './use-protect-tooltip-copy'; import type { TooltipContent } from './use-protect-tooltip-copy'; import type { FC } from 'react'; @@ -39,11 +38,21 @@ const ProtectValueSection = () => { } return null; } - return sprintf( - /* translators: `\xa0` is a non-breaking space. %1$d is the number (integer) of plugins and %2$d is the number (integer) of themes the site has. */ - __( '%1$d plugins &\xa0%2$d\xa0themes', 'jetpack-my-jetpack' ), - pluginsCount, - themesCount + return ( + sprintf( + /* translators: %d is the number of plugins installed on the site. */ + _n( '%d plugin', '%d plugins', pluginsCount, 'jetpack-my-jetpack' ), + pluginsCount + ) + + ' ' + + /* translators: The ampersand symbol here (&) is meaning "and". */ + __( '&', 'jetpack-my-jetpack' ) + + '\xa0' + // `\xa0` is a non-breaking space. + sprintf( + /* translators: %d is the number of themes installed on the site. */ + _n( '%d theme', '%d themes', themesCount, 'jetpack-my-jetpack' ).replace( ' ', '\xa0' ), // `\xa0` is a non-breaking space. + themesCount + ) ); }, [ isPluginActive, timeSinceLastScan, pluginsCount, themesCount ] ); @@ -65,59 +74,30 @@ const ValueSection: FC< { lastScanText?: string; tooltipContent: TooltipContent; } > = ( { isProtectActive, lastScanText, tooltipContent } ) => { - const useTooltipRef = useRef< HTMLButtonElement >(); - const isMobileViewport: boolean = useViewportMatch( 'medium', '<' ); - const [ isPopoverVisible, setIsPopoverVisible ] = useState( false ); - // TODO: `scanThreatsTooltip` will be utilized in a followup PR. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { pluginsThemesTooltip, scanThreatsTooltip } = tooltipContent; - - const toggleTooltip = useCallback( - () => setIsPopoverVisible( prevState => ! prevState ), - [ setIsPopoverVisible ] - ); - const hideTooltip = useCallback( () => { - // Don't hide the tooltip here if it's the tooltip button that was clicked (the button - // becoming the document's activeElement). Instead let toggleTooltip() handle the closing. - if ( useTooltipRef.current && ! useTooltipRef.current.contains( document.activeElement ) ) { - setIsPopoverVisible( false ); - } - }, [ setIsPopoverVisible, useTooltipRef ] ); + const { pluginsThemesTooltip } = tooltipContent; return ( <>
{ lastScanText &&
{ lastScanText }
} { ! isProtectActive && ( -
- - { isPopoverVisible && ( - - <> -

{ pluginsThemesTooltip.title }

-

{ pluginsThemesTooltip.text }

- -
- ) } -
+ + <> +

{ pluginsThemesTooltip.title }

+

{ pluginsThemesTooltip.text }

+ +
) }
-
Scan
-
+
Auto-Firewall
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/scan-threats.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/scan-threats.tsx new file mode 100644 index 0000000000000..055f9f39976b7 --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/scan-threats.tsx @@ -0,0 +1,253 @@ +import { Gridicon } from '@automattic/jetpack-components'; +import { Popover } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { useMemo, useState, useCallback, useRef } from 'react'; +import useProduct from '../../../data/products/use-product'; +import { getMyJetpackWindowInitialState } from '../../../data/utils/get-my-jetpack-window-state'; +import useAnalytics from '../../../hooks/use-analytics'; +import useMyJetpackConnection from '../../../hooks/use-my-jetpack-connection'; +import ShieldOff from './assets/shield-off.svg'; +import ShieldPartial from './assets/shield-partial.svg'; +import ShieldSuccess from './assets/shield-success.svg'; +import { InfoTooltip } from './info-tooltip'; +import { useProtectTooltipCopy, type TooltipContent } from './use-protect-tooltip-copy'; +import type { ReactElement, PropsWithChildren } from 'react'; + +export const ScanAndThreatStatus = () => { + const slug = 'protect'; + const { detail } = useProduct( slug ); + const { isPluginActive = false, hasPaidPlanForProduct: hasProtectPaidPlan } = detail || {}; + const { isSiteConnected } = useMyJetpackConnection(); + const { plugins, themes, scanData } = getMyJetpackWindowInitialState(); + const { + plugins: fromScanPlugins, + themes: fromScanThemes, + num_threats: numThreats = 0, + } = scanData; + + const pluginsCount = fromScanPlugins.length || Object.keys( plugins ).length; + const themesCount = fromScanThemes.length || Object.keys( themes ).length; + const tooltipContent = useProtectTooltipCopy( { pluginsCount, themesCount, numThreats } ); + const { scanThreatsTooltip } = tooltipContent; + + const criticalScanThreatCount = useMemo( () => { + const { core, database, files, num_plugins_threats, num_themes_threats } = scanData; + const pluginsThreats = num_plugins_threats + ? fromScanPlugins.reduce( ( accum, plugin ) => accum.concat( plugin.threats ), [] ) + : []; + const themesThreats = num_themes_threats + ? fromScanThemes.reduce( ( accum, theme ) => accum.concat( theme.threats ), [] ) + : []; + const allThreats = [ + ...pluginsThreats, + ...themesThreats, + ...( core?.threats ?? [] ), + ...database, + ...files, + ]; + return allThreats.reduce( + ( accum, threat ) => ( threat.severity >= 5 ? ( accum += 1 ) : accum ), + 0 + ); + }, [ fromScanPlugins, fromScanThemes, scanData ] ); + + if ( isPluginActive && isSiteConnected ) { + if ( hasProtectPaidPlan ) { + if ( numThreats ) { + return ( + + ); + } + return ; + } + return numThreats ? ( + + ) : ( + + ); + } + + return ; +}; + +/** + * ThreatStatus component + * + * @param {PropsWithChildren} props - The component props + * @param {number} props.numThreats - The number of threats + * @param {number} props.criticalThreatCount - The number of critical threats + * @param {TooltipContent[ 'scanThreatsTooltip' ]} props.tooltipContent - The number of critical threats + * @returns {ReactElement} rendered component + */ +function ThreatStatus( { + numThreats, + criticalThreatCount, + tooltipContent, +}: { + numThreats: number; + criticalThreatCount?: number; + tooltipContent?: TooltipContent[ 'scanThreatsTooltip' ]; +} ) { + const { recordEvent } = useAnalytics(); + const useTooltipRef = useRef< HTMLButtonElement >(); + const isMobileViewport: boolean = useViewportMatch( 'medium', '<' ); + const [ isPopoverVisible, setIsPopoverVisible ] = useState( false ); + + const toggleTooltip = useCallback( + () => + setIsPopoverVisible( prevState => { + if ( ! prevState === true ) { + recordEvent( 'jetpack_protect_card_tooltip_open', { + page: 'my-jetpack', + feature: 'jetpack-protect', + location: 'scan', + status: 'alert', + hasPaidPlan: true, + threats: numThreats, + } ); + } + return ! prevState; + } ), + [ numThreats, recordEvent ] + ); + const hideTooltip = useCallback( () => { + // Don't hide the tooltip here if it's the tooltip button that was clicked (the button + // becoming the document's activeElement). Instead let toggleTooltip() handle the closing. + if ( useTooltipRef.current && ! useTooltipRef.current.contains( document.activeElement ) ) { + setIsPopoverVisible( false ); + } + }, [ setIsPopoverVisible, useTooltipRef ] ); + + if ( criticalThreatCount ) { + return ( + <> +
{ __( 'Threats', 'jetpack-my-jetpack' ) }
+
+
+
{ numThreats }
+
+ + { isPopoverVisible && ( + + <> +

{ tooltipContent.title }

+

{ tooltipContent.text }

+ +
+ ) } +
+
+
+ + ); + } + + return ( + <> +
{ __( 'Threats', 'jetpack-my-jetpack' ) }
+
+
{ numThreats }
+
+ + ); +} + +/** + * ScanStatus component + * + * @param {PropsWithChildren} props - The component props + * @param {'success' | 'partial' | 'off'} props.status - The number of threats + * @param {TooltipContent[ 'scanThreatsTooltip' ]} props.tooltipContent - The number of critical threats + * @returns { ReactElement} rendered component + */ +function ScanStatus( { + status, + tooltipContent, +}: { + status: 'success' | 'partial' | 'off'; + tooltipContent?: TooltipContent[ 'scanThreatsTooltip' ]; +} ) { + if ( status === 'success' ) { + return ( + <> +
{ __( 'Scan', 'jetpack-my-jetpack' ) }
+
+
+ { +
+
{ __( 'Secure', 'jetpack-my-jetpack' ) }
+
+ + ); + } + if ( status === 'partial' ) { + return ( + <> +
{ __( 'Scan', 'jetpack-my-jetpack' ) }
+
+
+ { +
+
+ { __( 'Partial', 'jetpack-my-jetpack' ) } +
+ + <> +

{ tooltipContent.title }

+

{ tooltipContent.text }

+ +
+
+ + ); + } + return ( + <> +
{ __( 'Scan', 'jetpack-my-jetpack' ) }
+
+
+ { +
+
{ __( 'Off', 'jetpack-my-jetpack' ) }
+
+ + ); +} diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss index f2f6af7938b61..3b82be661559d 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss @@ -1,11 +1,14 @@ .value-section { display: flex; justify-content: space-between; + margin-top: calc(var(--spacing-base) / 2); &__heading { font-size: var(--font-body-extra-small); color: var(--jp-gray-100); font-weight: 500; + margin-bottom: calc(var(--spacing-base) + 2px); + line-height: var(--font-title-small); } &__last-scan { @@ -15,7 +18,7 @@ margin-top: var(--spacing-base); column-gap: 1px; position: absolute; - right: calc(var(--spacing-base)*3); + right: calc(var(--spacing-base) * 3); width: calc(57% - (var(--spacing-base) * 3)); div { font-size: var(--font-body-extra-small); @@ -31,24 +34,11 @@ } } - &__tooltip-button { - display: flex; - align-items: center; - background: transparent; - border: none; - color: var(--jp-gray-50); - padding: 2px; - cursor: pointer; - svg { - margin: 0 auto; - } - } - &__tooltip-heading { font-size: var(--font-title-small); - line-height: 30px; + line-height: calc(var(--font-title-small) + 6px);; color: var(--jp-black); - margin: 0 0 16px; + margin: 0 0 calc(var(--spacing-base) * 2); font-weight: 500; } @@ -57,4 +47,53 @@ line-height: var(--font-title-small); color: var(--jp-gray-70); } + + &__data { + display: flex; + align-items: center; + font-size: var(--font-body-extra-small); + line-height: var(--font-title-small); + color: var(--jp-gray-50); + font-weight: 500; + } + + &__status-icon { + display: block; + margin-right: calc(var(--spacing-base) - 2px); + } + + &__status-text { + margin-right: 1px; + // Reduce the letter spacing slightly. + // The specific letter spacing is per the design mockup. + letter-spacing: -0.24px; // https://wp.me/p1HpG7-rFA + } } + +.scan-threats { + &__threat-count { + font-size: calc(var(--font-title-large) - 4px); + line-height: var(--font-title-large); + font-weight: 400; + color: var(--jp-black); + } + + &__critical-threats { + display: flex; + align-items: center; + } + + &__critical-threat-container { + margin-left: 1px; + > button > svg { + fill: var(--jp-red-50); + } + } + + &__critical-threat-count { + color: var(--jp-red-50); + margin-left: calc(var(--spacing-base) / 4); + } +} + + diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts index 6503be581edf0..1f51776b2153f 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts @@ -1,4 +1,4 @@ -import { __, sprintf } from '@wordpress/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; import useProduct from '../../../data/products/use-product'; import type { ReactElement } from 'react'; @@ -36,13 +36,21 @@ export function useProtectTooltipCopy( { pluginsThemesTooltip: { title: __( 'Improve site safety: secure plugins & themes', 'jetpack-my-jetpack' ), text: sprintf( - /* translators: %1$d is the number of plugins and %2$d is the number of themes installed on the site. */ + /* translators: %1$s the singular or plural of number of plugin(s), and %2$s is the singular or plural of the number of theme(s). */ __( - 'Your site has %1$d plugins and %2$d themes lacking security measures. Improve your site’s safety by adding protection at no cost.', + 'Your site has %1$s and %2$s lacking security measures. Improve your site’s safety by adding protection at no cost.', 'jetpack-my-jetpack' ), - pluginsCount, - themesCount + sprintf( + /* translators: %d is the number of plugins installed on the site. */ + _n( '%d plugin', '%d plugins', pluginsCount, 'jetpack-my-jetpack' ), + pluginsCount + ), + sprintf( + /* translators: %d is the number of themes installed on the site. */ + _n( '%d theme', '%d themes', themesCount, 'jetpack-my-jetpack' ), + themesCount + ) ), }, scanThreatsTooltip: @@ -50,12 +58,21 @@ export function useProtectTooltipCopy( { ? { title: __( 'Auto-fix threats', 'jetpack-my-jetpack' ), text: sprintf( - /* translators: %d is the number of detected scan threats on the site. */ + /* translators: %s is the singular or plural of number of detected critical threats on the site. */ __( - 'The last scan identified %d critical threats. But don’t worry, use the “Auto-fix” button in the product to automatically fix most threats.', + 'The last scan identified %s. But don’t worry, use the “Auto-fix” button in the product to automatically fix most threats.', 'jetpack-my-jetpack' ), - numThreats + sprintf( + /* translators: %d is the number of detected scan threats on the site. */ + _n( + '%d critical threat.', + '%d critical threats.', + numThreats, + 'jetpack-my-jetpack' + ), + numThreats + ) ), } : { diff --git a/projects/packages/my-jetpack/changelog/add-my-protect-card-scan-threats b/projects/packages/my-jetpack/changelog/add-my-protect-card-scan-threats new file mode 100644 index 0000000000000..d8d47d70e31dc --- /dev/null +++ b/projects/packages/my-jetpack/changelog/add-my-protect-card-scan-threats @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add scan/threat info to the Protect card in My Jetpack. diff --git a/projects/packages/my-jetpack/global.d.ts b/projects/packages/my-jetpack/global.d.ts index 8cdd63114d5bb..e8fc1136d62a1 100644 --- a/projects/packages/my-jetpack/global.d.ts +++ b/projects/packages/my-jetpack/global.d.ts @@ -27,11 +27,28 @@ type JetpackModule = | 'stats' | 'ai'; +type ThreatItem = { + // Protect API properties (free plan) + id: string; + title: string; + fixed_in: string; + description: string | null; + source: string | null; + // Scan API properties (paid plan) + context: string | null; + filename: string | null; + first_detected: string | null; + fixable: boolean | null; + severity: number | null; + signature: string | null; + status: number | null; +}; + type ScanItem = { checked: boolean; name: string; slug: string; - threats: string[]; + threats: ThreatItem[]; type: string; version: string; }; diff --git a/projects/packages/my-jetpack/src/products/class-protect.php b/projects/packages/my-jetpack/src/products/class-protect.php index 331ea2b46e58d..70624db3a0526 100644 --- a/projects/packages/my-jetpack/src/products/class-protect.php +++ b/projects/packages/my-jetpack/src/products/class-protect.php @@ -255,16 +255,37 @@ public static function get_pricing_for_ui() { } /** - * Checks if the site has a paid plan for the product + * Checks whether the current plan (or purchases) of the site already supports the product * - * @return bool + * @return boolean */ public static function has_paid_plan_for_product() { - $scan_data = static::get_state_from_wpcom(); - if ( is_wp_error( $scan_data ) ) { + $purchases_data = Wpcom_Products::get_site_current_purchases(); + if ( is_wp_error( $purchases_data ) ) { return false; } - return is_object( $scan_data ) && isset( $scan_data->state ) && 'unavailable' !== $scan_data->state; + if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) { + foreach ( $purchases_data as $purchase ) { + // Protect is available as jetpack_scan product and as part of the Security or Complete plan. + if ( + strpos( $purchase->product_slug, 'jetpack_scan' ) !== false || + str_starts_with( $purchase->product_slug, 'jetpack_security' ) || + str_starts_with( $purchase->product_slug, 'jetpack_complete' ) + ) { + return true; + } + } + } + return false; + } + + /** + * Checks whether the product can be upgraded - i.e. this shows the /#add-protect interstitial + * + * @return boolean + */ + public static function is_upgradable() { + return ! self::has_paid_plan_for_product(); } /** @@ -292,6 +313,6 @@ public static function get_manage_url() { * @return array Products bundle list. */ public static function is_upgradable_by_bundle() { - return array( 'security' ); + return array( 'security', 'complete' ); } }