Skip to content

Commit d1a73a0

Browse files
dognose24claude
andcommitted
Stats: hide Jetpack upsells for products the site already owns
The Odyssey Stats upsell section showed a product whenever the site was missing any one of the product's feature slugs, so a site on Jetpack Security (which bundles VaultPress Backup) was still prompted to buy Backup, and only checkout revealed the product was already included. Invert the check: each upsell now lists the entitlement features that mean the product is owned, and the card is hidden when the site has all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9074d8c commit d1a73a0

3 files changed

Lines changed: 75 additions & 43 deletions

File tree

client/my-sites/stats/jetpack-upsell-section/index.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getSiteTitle } from 'calypso/state/sites/selectors';
66
import { getSelectedSiteId } from 'calypso/state/ui/selectors';
77
import UpsellCard from './upsell-card';
88
import {
9+
filterUpsellsBySiteFeatures,
910
getAvailableUpsells,
1011
getUpsellFeatureSlugs,
1112
Product,
@@ -43,11 +44,9 @@ function getVisibleUpsells( siteId: number | null, siteFeatures: string[] ): Pro
4344
return [];
4445
}
4546

46-
// Filter available upsells against site features.
47-
// If an upsell has even one feature that is not active on the site, present it to the user.
48-
const filteredUpsells = getAvailableUpsells().filter( ( upsell ) =>
49-
upsell.features.some( ( feature ) => ! siteFeatures.includes( feature ) )
50-
);
47+
// Hide upsells for products the site already owns, either directly or through
48+
// a bundling plan (e.g. don't upsell Backup to a site on Jetpack Security).
49+
const filteredUpsells = filterUpsellsBySiteFeatures( getAvailableUpsells(), siteFeatures );
5150

5251
// Add the checkout URL to the results.
5352
const finalUpsells = filteredUpsells.map( ( upsell ) => {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { filterUpsellsBySiteFeatures, getAvailableUpsells } from '../upsell-card/available-upsells';
2+
3+
// Feature slugs a site on Jetpack Security (Daily) reports: Backup, Scan and
4+
// Anti-spam are bundled, but not Search, VideoPress, Boost or Social.
5+
const SECURITY_DAILY_FEATURES = [
6+
'antispam',
7+
'backups',
8+
'backups-daily',
9+
'full-activity-log',
10+
'scan',
11+
];
12+
13+
describe( 'filterUpsellsBySiteFeatures', () => {
14+
const upsells = getAvailableUpsells();
15+
const visibleSlugs = ( siteFeatures: string[] ) =>
16+
filterUpsellsBySiteFeatures( upsells, siteFeatures ).map( ( upsell ) => upsell.slug );
17+
18+
it( 'shows all upsells when the site has no features', () => {
19+
expect( visibleSlugs( [] ) ).toEqual( [
20+
'security',
21+
'backup',
22+
'search',
23+
'video',
24+
'boost',
25+
'social',
26+
] );
27+
} );
28+
29+
it( 'hides the Backup and Security upsells when a plan already bundles them', () => {
30+
expect( visibleSlugs( SECURITY_DAILY_FEATURES ) ).toEqual( [
31+
'search',
32+
'video',
33+
'boost',
34+
'social',
35+
] );
36+
} );
37+
38+
it( 'hides the Backup upsell but keeps Security when only Backup is owned', () => {
39+
expect( visibleSlugs( [ 'backups' ] ) ).toEqual( [
40+
'security',
41+
'search',
42+
'video',
43+
'boost',
44+
'social',
45+
] );
46+
} );
47+
48+
it( 'hides every upsell when the site has all owned features', () => {
49+
const allOwnedFeatures = upsells.flatMap( ( upsell ) => upsell.ownedFeatures );
50+
expect( visibleSlugs( allOwnedFeatures ) ).toEqual( [] );
51+
} );
52+
} );

client/my-sites/stats/jetpack-upsell-section/upsell-card/available-upsells.tsx

Lines changed: 19 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export type Product = {
2121
isFree: boolean;
2222
slug: string;
2323
title: string;
24-
features: string[];
24+
// Feature slugs that indicate the product (or a plan bundling it) is already
25+
// owned. The upsell is hidden when the site has all of them.
26+
ownedFeatures: string[];
2527
checkoutSlug: string;
2628
checkoutUrl: string | null;
2729
};
@@ -37,26 +39,7 @@ export function getAvailableUpsells() {
3739
isFree: false,
3840
slug: 'security',
3941
title: 'Security',
40-
features: [
41-
'akismet',
42-
'antispam',
43-
'backups',
44-
'backups-daily',
45-
'core/audio',
46-
'full-activity-log',
47-
'google-analytics',
48-
'google-my-business',
49-
'priority_support',
50-
'real-time-backups',
51-
'scan',
52-
'simple-payments',
53-
'subscriber-unlimited-imports',
54-
'support',
55-
'vaultpress-backups',
56-
'video-hosting',
57-
'wordads',
58-
'wordads-jetpack',
59-
],
42+
ownedFeatures: [ 'backups', 'scan', 'antispam' ],
6043
checkoutSlug: PLAN_JETPACK_SECURITY_T1_YEARLY,
6144
},
6245
{
@@ -68,7 +51,7 @@ export function getAvailableUpsells() {
6851
isFree: false,
6952
slug: 'backup',
7053
title: 'Backup',
71-
features: [ 'backups', 'full-activity-log', 'real-time-backups', 'priority_support' ],
54+
ownedFeatures: [ 'backups' ],
7255
checkoutSlug: PRODUCT_JETPACK_BACKUP_T1_YEARLY,
7356
},
7457
{
@@ -80,7 +63,7 @@ export function getAvailableUpsells() {
8063
isFree: false,
8164
slug: 'search',
8265
title: 'Search',
83-
features: [ 'search', 'instant-search' ],
66+
ownedFeatures: [ 'search' ],
8467
checkoutSlug: PRODUCT_JETPACK_SEARCH,
8568
},
8669
{
@@ -92,7 +75,7 @@ export function getAvailableUpsells() {
9275
isFree: false,
9376
slug: 'video',
9477
title: 'VideoPress',
95-
features: [ 'videopress', 'videopress-1tb-storage' ],
78+
ownedFeatures: [ 'videopress' ],
9679
checkoutSlug: PRODUCT_JETPACK_VIDEOPRESS,
9780
},
9881
{
@@ -104,14 +87,7 @@ export function getAvailableUpsells() {
10487
isFree: true,
10588
slug: 'boost',
10689
title: 'Boost',
107-
features: [
108-
'cloud-critical-css',
109-
'cornerstone-10-pages',
110-
'image-cdn-liar',
111-
'image-cdn-quality',
112-
'image-size-analysis',
113-
'performance-history',
114-
],
90+
ownedFeatures: [ 'cloud-critical-css' ],
11591
checkoutSlug: PRODUCT_JETPACK_BOOST,
11692
},
11793
{
@@ -123,11 +99,7 @@ export function getAvailableUpsells() {
12399
isFree: true,
124100
slug: 'social',
125101
title: 'Social',
126-
features: [
127-
'social-enhanced-publishing',
128-
'social-image-generator',
129-
'subscriber-unlimited-imports',
130-
],
102+
ownedFeatures: [ 'social-enhanced-publishing' ],
131103
checkoutSlug: PRODUCT_JETPACK_SOCIAL_BASIC,
132104
},
133105
] as Product[];
@@ -137,5 +109,14 @@ export function getAvailableUpsells() {
137109
// Currently we end up calling getAvailableUpsells() twice per render.
138110
export function getUpsellFeatureSlugs(): string[] {
139111
const upsells = getAvailableUpsells();
140-
return upsells.flatMap( ( upsell ) => upsell.features );
112+
return upsells.flatMap( ( upsell ) => upsell.ownedFeatures );
113+
}
114+
115+
export function filterUpsellsBySiteFeatures(
116+
upsells: Product[],
117+
siteFeatures: string[]
118+
): Product[] {
119+
return upsells.filter(
120+
( upsell ) => ! upsell.ownedFeatures.every( ( feature ) => siteFeatures.includes( feature ) )
121+
);
141122
}

0 commit comments

Comments
 (0)