diff --git a/projects/js-packages/components/changelog/add-hide-value-prop-to-stat-card b/projects/js-packages/components/changelog/add-hide-value-prop-to-stat-card new file mode 100644 index 0000000000000..0d4002c768dd8 --- /dev/null +++ b/projects/js-packages/components/changelog/add-hide-value-prop-to-stat-card @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Stat Card: add hideValue prop diff --git a/projects/js-packages/components/components/stat-card/index.tsx b/projects/js-packages/components/components/stat-card/index.tsx index b6854dc02f37e..222cafb44068e 100644 --- a/projects/js-packages/components/components/stat-card/index.tsx +++ b/projects/js-packages/components/components/stat-card/index.tsx @@ -18,7 +18,14 @@ import type React from 'react'; * @param {StatCardProps} props - Component props. * @return {React.ReactNode} - StatCard react component. */ -const StatCard = ( { className, icon, label, value, variant = 'square' }: StatCardProps ) => { +const StatCard = ( { + className, + icon, + label, + value, + variant = 'square', + hideValue = false, +}: StatCardProps ) => { const formattedValue = numberFormat( value ); const compactValue = numberFormat( value, { notation: 'compact', @@ -33,12 +40,12 @@ const StatCard = ( { className, icon, label, value, variant = 'square' }: StatCa { variant === 'square' ? ( - { compactValue } + { hideValue ? '-' : compactValue } ) : ( - { formattedValue } + { hideValue ? '-' : formattedValue } ) } diff --git a/projects/js-packages/components/components/stat-card/types.ts b/projects/js-packages/components/components/stat-card/types.ts index 4b0fd698e6774..8e1c0e99d6d60 100644 --- a/projects/js-packages/components/components/stat-card/types.ts +++ b/projects/js-packages/components/components/stat-card/types.ts @@ -25,4 +25,9 @@ export type StatCardProps = { * @default 'square' */ variant?: 'square' | 'horizontal'; + + /** + * Whether to hide the value. + */ + hideValue?: boolean; }; diff --git a/projects/plugins/protect/changelog/add-protect-home b/projects/plugins/protect/changelog/add-protect-home new file mode 100644 index 0000000000000..0bcfedb6fe8ac --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-home @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds a Home page and StatCards diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index 293ccdaeb3ce7..492cded402990 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -457,8 +457,9 @@ public static function get_waf_stats() { } return array( - 'blockedRequests' => Plan::has_required_plan() ? Waf_Stats::get_blocked_requests() : false, + 'blockedRequests' => Waf_Stats::get_blocked_requests(), 'automaticRulesLastUpdated' => Waf_Stats::get_automatic_rules_last_updated(), + 'blockedLogins' => (int) get_option( 'jetpack_protect_blocked_attempts', 0 ), ); } } diff --git a/projects/plugins/protect/src/js/components/admin-page/index.jsx b/projects/plugins/protect/src/js/components/admin-page/index.jsx index 68f9359a9bd81..5811238cd266e 100644 --- a/projects/plugins/protect/src/js/components/admin-page/index.jsx +++ b/projects/plugins/protect/src/js/components/admin-page/index.jsx @@ -63,6 +63,7 @@ const AdminPage = ( { children } ) => { { notice && } + { const getProtectFree = useCallback( async () => { recordEvent( 'jetpack_protect_connected_product_activated' ); await connectSiteMutation.mutateAsync(); - navigate( '/scan' ); + navigate( '/' ); }, [ connectSiteMutation, recordEvent, navigate ] ); const args = { diff --git a/projects/plugins/protect/src/js/components/seventy-five-layout/index.tsx b/projects/plugins/protect/src/js/components/seventy-five-layout/index.tsx deleted file mode 100644 index 19ee4309e55a5..0000000000000 --- a/projects/plugins/protect/src/js/components/seventy-five-layout/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Container, Col, useBreakpointMatch } from '@automattic/jetpack-components'; -import React from 'react'; - -// Define the props interface for the SeventyFiveLayout component -interface SeventyFiveLayoutProps { - spacing?: number; - gap?: number; - main: React.ReactNode; - mainClassName?: string; - secondary: React.ReactNode; - secondaryClassName?: string; - preserveSecondaryOnMobile?: boolean; - fluid?: boolean; -} - -/** - * SeventyFive layout meta component - * The component name references to - * the sections disposition of the layout. - * FiftyFifty, 75, thus 7|5 means the cols numbers - * for main and secondary sections respectively, - * in large lg viewport size. - * - * @param {object} props - Component props - * @param {number} props.spacing - Horizontal spacing - * @param {number} props.gap - Horizontal gap - * @param {React.ReactNode} props.main - Main section component - * @param {string} props.mainClassName - Main section class name - * @param {React.ReactNode} props.secondary - Secondary section component - * @param {string} props.secondaryClassName - Secondary section class name - * @param {boolean} props.preserveSecondaryOnMobile - Whether to show secondary section on mobile - * @param {boolean} props.fluid - Whether to use fluid layout - * @return {React.ReactNode} - React meta-component - */ -const SeventyFiveLayout: React.FC< SeventyFiveLayoutProps > = ( { - spacing = 0, - gap = 0, - main, - mainClassName, - secondary, - secondaryClassName, - preserveSecondaryOnMobile = false, - fluid, -} ) => { - // Ensure the correct typing for useBreakpointMatch - const [ isSmall, isLarge ] = useBreakpointMatch( [ 'sm', 'lg' ] ); - - /* - * By convention, secondary section is not shown when: - * - preserveSecondaryOnMobile is false - * - on mobile breakpoint (sm) - */ - const hideSecondarySection = ! preserveSecondaryOnMobile && isSmall; - - return ( - - { ! hideSecondarySection && ( - <> - - { main } - - { isLarge && } - - { secondary } - - - ) } - { hideSecondarySection && { main } } - - ); -}; - -export default SeventyFiveLayout; diff --git a/projects/plugins/protect/src/js/components/seventy-five-layout/styles.module.scss b/projects/plugins/protect/src/js/components/seventy-five-layout/styles.module.scss deleted file mode 100644 index 5405c6e28a9b4..0000000000000 --- a/projects/plugins/protect/src/js/components/seventy-five-layout/styles.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -// seventy-five layout -// Handle large lg size from here, -// adding a gap on one column -// in between main and secondary sections. -@media ( min-width: 960px ) { - .main { - grid-column: 1 / span 6; - } - - .secondary { - grid-column: 8 / span 5; - } -} diff --git a/projects/plugins/protect/src/js/hooks/use-plan.tsx b/projects/plugins/protect/src/js/hooks/use-plan.tsx index b5ab18da01875..f5cd1d54943b9 100644 --- a/projects/plugins/protect/src/js/hooks/use-plan.tsx +++ b/projects/plugins/protect/src/js/hooks/use-plan.tsx @@ -48,7 +48,7 @@ export default function usePlan( { redirectUrl }: { redirectUrl?: string } = {} const { run: checkout } = useProductCheckoutWorkflow( { productSlug: JETPACK_SCAN_SLUG, - redirectUrl: redirectUrl || adminUrl, + redirectUrl: redirectUrl || adminUrl + '#/scan', siteProductAvailabilityHandler: API.checkPlan, useBlogIdSuffix: true, connectAfterCheckout: false, diff --git a/projects/plugins/protect/src/js/index.tsx b/projects/plugins/protect/src/js/index.tsx index 2b91f4b090b92..4438d5021a664 100644 --- a/projects/plugins/protect/src/js/index.tsx +++ b/projects/plugins/protect/src/js/index.tsx @@ -11,6 +11,7 @@ import { NoticeProvider } from './hooks/use-notices'; import { OnboardingRenderedContextProvider } from './hooks/use-onboarding'; import { CheckoutProvider } from './hooks/use-plan'; import FirewallRoute from './routes/firewall'; +import HomeRoute from './routes/home'; import ScanRoute from './routes/scan'; import SetupRoute from './routes/setup'; import './styles.module.scss'; @@ -56,6 +57,7 @@ function render() { } /> + } /> } /> } /> - } /> + } /> diff --git a/projects/plugins/protect/src/js/routes/home/home-admin-section-hero.tsx b/projects/plugins/protect/src/js/routes/home/home-admin-section-hero.tsx new file mode 100644 index 0000000000000..12d887e933f43 --- /dev/null +++ b/projects/plugins/protect/src/js/routes/home/home-admin-section-hero.tsx @@ -0,0 +1,50 @@ +import { Text, Button } from '@automattic/jetpack-components'; +import { __ } from '@wordpress/i18n'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import AdminSectionHero from '../../components/admin-section-hero'; +import usePlan from '../../hooks/use-plan'; +import HomeStatCards from './home-statcards'; +import styles from './styles.module.scss'; + +const HomeAdminSectionHero: React.FC = () => { + const { hasPlan } = usePlan(); + const navigate = useNavigate(); + const handleScanReportClick = useCallback( () => { + navigate( '/scan' ); + }, [ navigate ] ); + + return ( + + + <> + + { __( 'Your site is safe with us', 'jetpack-protect' ) } + + + { hasPlan + ? __( + 'We stay ahead of security threats to keep your site protected.', + 'jetpack-protect' + ) + : __( + 'We stay ahead of security vulnerabilities to keep your site protected.', + 'jetpack-protect' + ) } + + + + + { } + + ); +}; + +export default HomeAdminSectionHero; diff --git a/projects/plugins/protect/src/js/routes/home/home-statcards.jsx b/projects/plugins/protect/src/js/routes/home/home-statcards.jsx new file mode 100644 index 0000000000000..2d1dc34cac147 --- /dev/null +++ b/projects/plugins/protect/src/js/routes/home/home-statcards.jsx @@ -0,0 +1,274 @@ +import { Text, useBreakpointMatch, StatCard, ShieldIcon } from '@automattic/jetpack-components'; +import { Spinner, Tooltip } from '@wordpress/components'; +import { dateI18n } from '@wordpress/date'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useMemo } from 'react'; +import useScanStatusQuery, { isScanInProgress } from '../../data/scan/use-scan-status-query'; +import usePlan from '../../hooks/use-plan'; +import useWafData from '../../hooks/use-waf-data'; +import styles from './styles.module.scss'; + +const IconWithLabel = ( { label, isSmall, icon } ) => ( + + { icon } + { ! isSmall && ( + + { label } + + ) } + +); + +const HomeStatCard = ( { text, args } ) => ( + +
+ +
+
+); + +const HomeStatCards = () => { + const ICON_HEIGHT = 20; + + const { hasPlan } = usePlan(); + const [ isSmall ] = useBreakpointMatch( [ 'sm', 'lg' ], [ null, '<' ] ); + + const { data: status } = useScanStatusQuery(); + const scanning = isScanInProgress( status ); + const numThreats = status.threats.length; + const scanError = status.error; + + let lastCheckedLocalTimestamp = null; + if ( status.lastChecked ) { + // Convert the lastChecked UTC date to a local timestamp + lastCheckedLocalTimestamp = dateI18n( + 'F jS g:i A', + new Date( status.lastChecked + ' UTC' ).getTime(), + false + ); + } + + const { + config: { bruteForceProtection: isBruteForceModuleEnabled }, + isEnabled: isWafModuleEnabled, + wafSupported, + stats, + } = useWafData(); + + const { + blockedRequests: { allTime: allTimeBlockedRequestsCount = 0 } = {}, + blockedLogins: allTimeBlockedLoginsCount = 0, + } = stats || {}; + + const variant = useMemo( () => ( isSmall ? 'horizontal' : 'square' ), [ isSmall ] ); + + const lastCheckedMessage = useMemo( () => { + if ( scanning ) { + return __( 'Your results will be ready soon.', 'jetpack-protect' ); + } + + if ( scanError ) { + return __( + 'Please check your connection or try scanning again in a few minutes.', + 'jetpack-protect' + ); + } + + if ( lastCheckedLocalTimestamp ) { + if ( numThreats > 0 ) { + if ( hasPlan ) { + return sprintf( + // translators: %1$s: date/time, %2$d: number + _n( + 'Last checked on %1$s: We found %2$d threat.', + 'Last checked on %1$s: We found %2$d threats.', + numThreats, + 'jetpack-protect' + ), + lastCheckedLocalTimestamp, + numThreats + ); + } + return sprintf( + // translators: %1$s: date/time, %2$d: number + _n( + 'Last checked on %1$s: We found %2$d vulnerability.', + 'Last checked on %1$s: We found %2$d vulnerabilities.', + numThreats, + 'jetpack-protect' + ), + lastCheckedLocalTimestamp, + numThreats + ); + } + return sprintf( + // translators: %s: date/time + __( 'Last checked on %s: Your site is secure.', 'jetpack-protect' ), + lastCheckedLocalTimestamp + ); + } + if ( hasPlan ) { + return sprintf( + // translators: %d: number + _n( + 'Last scan we found %d threat.', + 'Last scan we found %d threats.', + numThreats, + 'jetpack-protect' + ), + numThreats + ); + } + return sprintf( + // translators: %d: number + _n( + 'Last scan we found %2$d vulnerability.', + 'Last scan we found %2$d vulnerabilities.', + numThreats, + 'jetpack-protect' + ), + numThreats + ); + }, [ scanError, scanning, numThreats, lastCheckedLocalTimestamp, hasPlan ] ); + + const scanArgs = useMemo( () => { + let scanIcon; + if ( scanning ) { + scanIcon = ; + } else if ( scanError ) { + scanIcon = ; + } else { + scanIcon = ( + + ); + } + + let scanLabel; + if ( scanning ) { + scanLabel = __( 'One moment, pleaseā€¦', 'jetpack-protect' ); + } else if ( scanError ) { + scanLabel = __( 'An error occurred', 'jetpack-protect' ); + } else if ( hasPlan ) { + scanLabel = _n( 'Threat identified', 'Threats identified', numThreats, 'jetpack-protect' ); + } else { + scanLabel = _n( + 'Vulnerability identified', + 'Vulnerabilities identified', + numThreats, + 'jetpack-protect' + ); + } + + return { + variant, + icon: ( + + ), + label: { scanLabel }, + value: numThreats, + hideValue: !! ( scanError || scanning ), + }; + }, [ variant, scanning, ICON_HEIGHT, scanError, numThreats, hasPlan, isSmall ] ); + + const wafArgs = useMemo( + () => ( { + variant: variant, + className: isWafModuleEnabled ? styles.active : styles.disabled, + icon: ( + + + { ! isSmall && ( + + { __( 'Firewall', 'jetpack-protect' ) } + + ) } + + ), + label: ( + + { __( 'Blocked requests', 'jetpack-protect' ) } + + ), + value: allTimeBlockedRequestsCount, + hideValue: ! isWafModuleEnabled, + } ), + [ variant, isWafModuleEnabled, ICON_HEIGHT, isSmall, allTimeBlockedRequestsCount ] + ); + + const bruteForceArgs = useMemo( + () => ( { + variant: variant, + className: isBruteForceModuleEnabled ? styles.active : styles.disabled, + icon: ( + + + { ! isSmall && ( + + { __( 'Brute force', 'jetpack-protect' ) } + + ) } + + ), + label: ( + + { __( 'Blocked login attempts', 'jetpack-protect' ) } + + ), + value: allTimeBlockedLoginsCount, + hideValue: ! isBruteForceModuleEnabled, + } ), + [ variant, isBruteForceModuleEnabled, ICON_HEIGHT, isSmall, allTimeBlockedLoginsCount ] + ); + + return ( +
+ + { wafSupported && ( + + ) } + +
+ ); +}; + +export default HomeStatCards; diff --git a/projects/plugins/protect/src/js/routes/home/index.jsx b/projects/plugins/protect/src/js/routes/home/index.jsx new file mode 100644 index 0000000000000..718349caaac3f --- /dev/null +++ b/projects/plugins/protect/src/js/routes/home/index.jsx @@ -0,0 +1,25 @@ +import { AdminSection, Container, Col } from '@automattic/jetpack-components'; +import AdminPage from '../../components/admin-page'; +import HomeAdminSectionHero from './home-admin-section-hero'; + +/** + * Home Page + * + * The entry point for the Home page. + * + * @return {Component} The root component for the scan page. + */ +const HomePage = () => { + return ( + + + + + { /* TODO: Add ScanReport component here */ } + + + + ); +}; + +export default HomePage; diff --git a/projects/plugins/protect/src/js/routes/home/styles.module.scss b/projects/plugins/protect/src/js/routes/home/styles.module.scss new file mode 100644 index 0000000000000..b99bead52dbdb --- /dev/null +++ b/projects/plugins/protect/src/js/routes/home/styles.module.scss @@ -0,0 +1,69 @@ +.product-section, .info-section { + margin-top: calc( var( --spacing-base ) * 7 ); // 56px + margin-bottom: calc( var( --spacing-base ) * 7 ); // 56px +} + +.view-scan-report { + margin-top: calc( var( --spacing-base ) * 4 ); // 32px +} + +.stat-cards-wrapper { + display: flex; + justify-content: flex-start; + + > *:not( last-child ) { + margin-right: calc( var( --spacing-base ) * 3 ); // 24px + } + + .disabled { + opacity: 0.5; + } +} + +.stat-card-icon { + width: 100%; + margin-bottom: calc( var( --spacing-base ) * 3 ); // 24px + display: flex; + align-items: center; + gap: 8px; + + svg { + margin: 0; + } + + .active { + fill: var( --jp-green-40 ); + } + + .warning { + fill: var( --jp-yellow-40 ); + } + + .disabled { + fill: var( --jp-gray-40 ); + } + + &-label { + color: var( --jp-black ); + white-space: nowrap; + } +} + +.stat-card-tooltip { + margin-top: 8px; + max-width: 240px; + border-radius: 4px; + text-align: left; +} + + +@media ( max-width: 599px ) { + .stat-cards-wrapper { + flex-direction: column; + gap: var( --spacing-base ); // 8px + } + + .stat-card-icon { + margin-bottom: 0; + } +} \ No newline at end of file