Skip to content

Commit fe6a82b

Browse files
enejbkraftbj
andauthored
Protect dashboard: Show what Scan adds on the free Scan and Firewall cards (#53896)
* Protect dashboard: Show what Scan adds on the free Scan and Firewall cards * Protect dashboard: Share upsell tracking and identify the user once per page * Protect dashboard: Put the upsell button below the features on mobile and always label it Upgrade * Protect dashboard: Send upsell Tracks events on every free-plan site Load the Tracks script on the dashboard itself, since the admin-ui upgrade menu only loads it for sites with no products at all. Identify the user before the first event rather than in a parent mount effect, which runs after the cards record their views. Track the "rules stopped updating" row with the Firewall upsell events and `state: 'stopped'`, and check that paid and errored sites show no Upgrade link. * Protect dashboard: Tag upsell events with the blog ID and test the tracking Add the site's WordPress.com blog ID to every upsell event, since the analytics client only reads it from jpTracksContext, which this page doesn't set. Rotate the copy when the stored start time is in the future instead of keeping it until the clock catches up. Cover the Tracks script's consent gate, the identity fallback, the start variant and filter, identifying the user before the first event, and the Firewall rows' event properties. * Protect dashboard: Load a fresh tracks module with import() in its test CI's type check has no Node types, so require() fails there. --------- Co-authored-by: Brandon Kraft <public@brandonkraft.com>
1 parent 7cdb9d3 commit fe6a82b

28 files changed

Lines changed: 1055 additions & 22 deletions

‎pnpm-lock.yaml‎

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Protect dashboard: Show what Scan adds on the Scan and Firewall cards for sites without it, with an Upgrade button.

‎projects/packages/protect/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
]
4747
},
4848
"dependencies": {
49+
"@automattic/jetpack-analytics": "workspace:*",
4950
"@automattic/jetpack-base-styles": "workspace:*",
5051
"@automattic/jetpack-components": "workspace:*",
5152
"@automattic/jetpack-scan": "workspace:*",
53+
"@automattic/jetpack-script-data": "workspace:*",
5254
"@jetpack-protect/init": "link:packages/init",
5355
"@wordpress/admin-ui": "2.12.0",
5456
"@wordpress/api-fetch": "7.57.0",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useEffect } from '@wordpress/element';
2+
import { __ } from '@wordpress/i18n';
3+
import { Icon } from '@wordpress/icons';
4+
import { ButtonLink, Stack, Text } from '@wordpress/ui';
5+
import { recordEvent, recordViewOnce } from '../data/tracks';
6+
import { CardRow } from './card';
7+
import type { ReactElement } from 'react';
8+
9+
type Props = {
10+
heading: string;
11+
/** Three features, each an icon, a title and a description. */
12+
features: [ ReactElement, string, string ][];
13+
url: string;
14+
/** Tracks event prefix; `_view` and `_click` are recorded with `eventProps`. */
15+
event: string;
16+
eventProps?: Record< string, string | number >;
17+
};
18+
19+
/**
20+
* A card row selling an upgrade: a heading, the features it adds in columns, and a button.
21+
*
22+
* @param props - Component props.
23+
* @param props.heading - The pitch.
24+
* @param props.features - What the upgrade adds.
25+
* @param props.url - Where the button goes.
26+
* @param props.event - Tracks event prefix.
27+
* @param props.eventProps - Tracks event properties.
28+
* @return The row.
29+
*/
30+
export default function FeatureUpsell( { heading, features, url, event, eventProps = {} }: Props ) {
31+
// No dependencies: recordViewOnce drops repeats, and eventProps is a new object each render.
32+
useEffect( () => recordViewOnce( `${ event }_view`, eventProps ) );
33+
34+
return (
35+
<CardRow className="jp-protect-feature-upsell">
36+
<Stack direction="column" gap="md">
37+
<Text variant="body-md" className="jp-protect-feature-upsell__heading">
38+
{ heading }
39+
</Text>
40+
<div className="jp-protect-feature-upsell__body">
41+
<div className="jp-protect-feature-upsell__features">
42+
{ features.map( ( [ icon, title, description ] ) => (
43+
<Stack key={ title } direction="row" gap="sm" align="start">
44+
<Icon icon={ icon } size={ 24 } className="jp-protect-feature-upsell__icon" />
45+
<Stack direction="column" gap="xs">
46+
<Text variant="body-md" className="jp-protect-feature-upsell__title">
47+
{ title }
48+
</Text>
49+
<Text variant="body-sm" className="jp-protect-card__muted">
50+
{ description }
51+
</Text>
52+
</Stack>
53+
</Stack>
54+
) ) }
55+
</div>
56+
<ButtonLink
57+
href={ url }
58+
size="compact"
59+
className="jp-protect-feature-upsell__button"
60+
// eslint-disable-next-line react/jsx-no-bind -- ButtonLink isn't memoized, so a stable handler buys nothing.
61+
onClick={ () => recordEvent( `${ event }_click`, eventProps ) }
62+
>
63+
{ __( 'Upgrade', 'jetpack-protect-pkg' ) }
64+
</ButtonLink>
65+
</div>
66+
</Stack>
67+
</CardRow>
68+
);
69+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
type Queue = unknown[][];
2+
type TestWindow = { _tkq: Queue; JetpackScriptData: object };
3+
4+
/**
5+
* Record a view and a click with a fresh copy of the module, and return what reached the Tracks queue.
6+
*
7+
* @param scriptData - The page's `JetpackScriptData`.
8+
* @return The queued calls.
9+
*/
10+
async function queueFor( scriptData: object ): Promise< Queue > {
11+
const win = window as unknown as TestWindow;
12+
win._tkq = [];
13+
win.JetpackScriptData = scriptData;
14+
await jest.isolateModulesAsync( async () => {
15+
const { recordEvent, recordViewOnce } = await import( '../tracks' );
16+
recordViewOnce( 'jetpack_protect_test_view', { variant: 2 } );
17+
recordEvent( 'jetpack_protect_test_click', { variant: 2 } );
18+
} );
19+
return win._tkq;
20+
}
21+
22+
describe( 'tracks', () => {
23+
it.each( [
24+
[
25+
'identifies the user before the first event and tags each with the site',
26+
{
27+
user: { current_user: { wpcom: { ID: 7, login: 'wpcomuser' } } },
28+
site: { wpcom: { blog_id: 9 } },
29+
},
30+
[
31+
[ 'identifyUser', 7, 'wpcomuser' ],
32+
[ 'recordEvent', 'jetpack_protect_test_view', { blog_id: 9, variant: 2 } ],
33+
[ 'recordEvent', 'jetpack_protect_test_click', { blog_id: 9, variant: 2 } ],
34+
],
35+
],
36+
[
37+
'sends events without a user or site when there is no connection',
38+
{},
39+
[
40+
[ 'recordEvent', 'jetpack_protect_test_view', { variant: 2 } ],
41+
[ 'recordEvent', 'jetpack_protect_test_click', { variant: 2 } ],
42+
],
43+
],
44+
] )( '%s', async ( _, scriptData, expected ) => {
45+
await expect( queueFor( scriptData ) ).resolves.toEqual( expected );
46+
} );
47+
} );
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import analytics from '@automattic/jetpack-analytics';
2+
import { getScriptData } from '@automattic/jetpack-script-data';
3+
4+
type Props = Record< string, string | number >;
5+
6+
const viewed = new Set< string >();
7+
8+
let identified = false;
9+
10+
/**
11+
* Identify the connected user before the first event; a mount effect in a parent runs too late.
12+
*/
13+
function identify() {
14+
if ( identified ) {
15+
return;
16+
}
17+
identified = true;
18+
const wpcomUser = getScriptData()?.user?.current_user?.wpcom;
19+
if ( wpcomUser?.ID && wpcomUser?.login ) {
20+
analytics.initialize( wpcomUser.ID, wpcomUser.login );
21+
}
22+
}
23+
24+
/**
25+
* Add the site's WordPress.com blog ID, which the analytics client only takes from `jpTracksContext`.
26+
*
27+
* @param props - Event properties.
28+
* @return The properties to send.
29+
*/
30+
function withSite( props: Props ): Props {
31+
const blogId = getScriptData()?.site?.wpcom?.blog_id;
32+
return blogId ? { blog_id: blogId, ...props } : props;
33+
}
34+
35+
/**
36+
* Record a view once per page load, so returning to a tab doesn't count it again.
37+
*
38+
* @param event - Event name.
39+
* @param props - Event properties; a view with other properties counts separately.
40+
*/
41+
export function recordViewOnce( event: string, props: Props = {} ) {
42+
const key = event + JSON.stringify( props );
43+
if ( viewed.has( key ) ) {
44+
return;
45+
}
46+
viewed.add( key );
47+
identify();
48+
analytics.tracks.recordEvent( event, withSite( props ) );
49+
}
50+
51+
/**
52+
* Record an event.
53+
*
54+
* @param event - Event name.
55+
* @param props - Event properties.
56+
*/
57+
export function recordEvent( event: string, props: Props = {} ) {
58+
identify();
59+
analytics.tracks.recordEvent( event, withSite( props ) );
60+
}

‎projects/packages/protect/routes/dashboard/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
"version": "1.0.0",
33
"private": true,
44
"dependencies": {
5+
"@automattic/jetpack-analytics": "workspace:*",
56
"@automattic/jetpack-base-styles": "workspace:*",
67
"@automattic/jetpack-components": "workspace:*",
78
"@automattic/jetpack-scan": "workspace:*",
9+
"@automattic/jetpack-script-data": "workspace:*",
810
"@testing-library/react": "16.3.3",
911
"@testing-library/user-event": "14.6.8",
1012
"@wordpress/admin-ui": "2.12.0",

‎projects/packages/protect/routes/dashboard/route.scss‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,47 @@ body.toplevel_page_jetpack-protect {
128128
min-inline-size: 360px;
129129
}
130130
}
131+
132+
.jp-protect-feature-upsell {
133+
134+
&__heading,
135+
&__title {
136+
font-weight: 500;
137+
}
138+
139+
&__body {
140+
display: flex;
141+
gap: var(--wpds-dimension-gap-lg);
142+
align-items: center;
143+
justify-content: space-between;
144+
145+
// The button drops below the features, aligned with them.
146+
@media (max-width: 600px) {
147+
flex-direction: column;
148+
align-items: flex-start;
149+
}
150+
}
151+
152+
&__features {
153+
display: grid;
154+
flex: 1;
155+
grid-template-columns: repeat(3, minmax(0, 1fr));
156+
gap: var(--wpds-dimension-gap-lg);
157+
158+
@media (max-width: 600px) {
159+
grid-template-columns: minmax(0, 1fr);
160+
align-self: stretch;
161+
}
162+
}
163+
164+
&__icon {
165+
flex: none;
166+
fill: none;
167+
color: var(--wpds-color-foreground-interactive-brand);
168+
}
169+
170+
&__button {
171+
flex: none;
172+
white-space: nowrap;
173+
}
174+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { __ } from '@wordpress/i18n';
2+
import { published, rotateRight, shield } from '@wordpress/icons';
3+
import FeatureUpsell from '../../components/feature-upsell';
4+
5+
/**
6+
* The free plan's Firewall upsell: the automatic rules Scan adds.
7+
*
8+
* @param props - Component props.
9+
* @param props.url - Where to buy Scan.
10+
* @return The card row.
11+
*/
12+
export default function FirewallUpsell( { url }: { url: string } ) {
13+
return (
14+
<FeatureUpsell
15+
heading={ __( 'Let your firewall keep up with new attacks', 'jetpack-protect-pkg' ) }
16+
features={ [
17+
[
18+
shield,
19+
__( 'Block new attacks', 'jetpack-protect-pkg' ),
20+
__(
21+
'Automatic rules stop known attacks on WordPress, plugins and themes.',
22+
'jetpack-protect-pkg'
23+
),
24+
],
25+
[
26+
rotateRight,
27+
__( 'Always up to date', 'jetpack-protect-pkg' ),
28+
__(
29+
'New rules arrive as new attacks are found. You don’t update a thing.',
30+
'jetpack-protect-pkg'
31+
),
32+
],
33+
[
34+
published,
35+
__( 'Nothing to set up', 'jetpack-protect-pkg' ),
36+
__( 'Turn it on once and it works in the background.', 'jetpack-protect-pkg' ),
37+
],
38+
] }
39+
url={ url }
40+
event="jetpack_protect_dashboard_firewall_upsell"
41+
/>
42+
);
43+
}

‎projects/packages/protect/routes/dashboard/sections/firewall/overview-card.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import apiFetch from '@wordpress/api-fetch';
22
import { dispatch } from '@wordpress/data';
33
import { dateI18n } from '@wordpress/date';
44
import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
5-
import { __ } from '@wordpress/i18n';
5+
import { __, sprintf } from '@wordpress/i18n';
66
import { Icon, caution, check, closeSmall, info, shield } from '@wordpress/icons';
77
import { store as noticesStore } from '@wordpress/notices';
88
import { Button, Stack } from '@wordpress/ui';
@@ -24,10 +24,12 @@ import {
2424
runFirewallTest,
2525
} from './firewall-test';
2626
import FirewallToggle from './firewall-toggle';
27+
import FirewallUpsell from './firewall-upsell';
2728
import IpRule from './ip-rule';
2829
import RecentBlocks from './recent-blocks';
2930
import { AUTOMATIC_RULES, IP_ALLOW_LIST, IP_BLOCK_LIST } from './settings-keys';
3031
import TestDetails from './test-details';
32+
import UpgradeGap from './upgrade-gap';
3133
import type { TestOutcome, TestResult } from './firewall-test';
3234
import type { FirewallBlocks, FirewallContext, FirewallState } from './types';
3335
import type { JSX } from 'react';
@@ -41,6 +43,7 @@ const UNAVAILABLE: FirewallState = {
4143
blockedLast30Days: null,
4244
recentBlocks: [],
4345
hasScan: false,
46+
upgradeUrl: '',
4447
currentIp: '',
4548
ipRules: {},
4649
rulesUpdatedAt: null,
@@ -364,6 +367,28 @@ export default function FirewallOverviewCard( { state, settings, openTab }: Fire
364367
/>
365368
</CardRow>
366369
) }
370+
{ ! firewall.hasScan && rulesUpdatedAt === null && (
371+
<FirewallUpsell url={ firewall.upgradeUrl } />
372+
) }
373+
{ ! firewall.hasScan && rulesUpdatedAt !== null && (
374+
<UpgradeGap
375+
title={ __(
376+
'Your automatic firewall rules have stopped updating',
377+
'jetpack-protect-pkg'
378+
) }
379+
description={ sprintf(
380+
/* translators: %s is a date, such as "Mar 14, 2026". */
381+
__(
382+
'Your rules were last updated on %s. Attacks on WordPress plugins discovered since then can get through. Upgrade to get a new rule as soon as an attack is found.',
383+
'jetpack-protect-pkg'
384+
),
385+
dateI18n( 'M j, Y', rulesUpdatedAt * 1000, undefined )
386+
) }
387+
url={ firewall.upgradeUrl }
388+
event="jetpack_protect_dashboard_firewall_upsell"
389+
eventProps={ { state: 'stopped' } }
390+
/>
391+
) }
367392
</ProtectCard>
368393
);
369394
}

0 commit comments

Comments
 (0)