Skip to content

Commit 3e6f04a

Browse files
committed
Adds plan restriction to promos
- Supports plan-based promos
1 parent ce1036f commit 3e6f04a

File tree

19 files changed

+225
-95
lines changed

19 files changed

+225
-95
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ out
88
node_modules
99
images/settings
1010
gitlens-*.vsix
11+
product.json
1112
tsconfig*.tsbuildinfo

src/commands/quickCommand.steps.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
} from '../git/utils/reference.utils';
4444
import { getHighlanderProviderName } from '../git/utils/remote.utils';
4545
import { createRevisionRange, isRevisionRange } from '../git/utils/revision.utils';
46-
import { isSubscriptionPaidPlan } from '../plus/gk/utils/subscription.utils';
46+
import { getSubscriptionNextPaidPlanId, isSubscriptionPaidPlan } from '../plus/gk/utils/subscription.utils';
4747
import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad';
4848
import {
4949
CommitApplyFileChangesCommandQuickPickItem,
@@ -2753,7 +2753,11 @@ export async function* ensureAccessStep<
27532753
} else {
27542754
if (access.subscription.required == null) return access;
27552755

2756-
const promo = await container.productConfig.getApplicablePromo(access.subscription.current.state, 'gate');
2756+
const promo = await container.productConfig.getApplicablePromo(
2757+
access.subscription.current.state,
2758+
getSubscriptionNextPaidPlanId(access.subscription.current),
2759+
'gate',
2760+
);
27572761
const detail = promo?.content?.quickpick.detail;
27582762

27592763
switch (feature) {

src/constants.storage.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { GroupableTreeViewTypes } from './constants.views';
77
import type { Environment } from './container';
88
import type { FeaturePreviews } from './features';
99
import type { GitRevisionRangeNotation } from './git/models/revision';
10-
import type { Subscription } from './plus/gk/models/subscription';
10+
import type { PaidSubscriptionPlanIds, Subscription } from './plus/gk/models/subscription';
1111
import type { Integration } from './plus/integrations/models/integration';
1212
import type { DeepLinkServiceState } from './uris/deepLinks/deepLink';
1313

@@ -120,8 +120,9 @@ export interface StoredProductConfig {
120120
export interface StoredPromo {
121121
key: string;
122122
code?: string;
123-
locations?: ('account' | 'badge' | 'gate' | 'home')[];
123+
plan?: PaidSubscriptionPlanIds;
124124
states?: SubscriptionState[];
125+
locations?: ('account' | 'badge' | 'gate' | 'home')[];
125126
expiresOn?: number;
126127
startsOn?: number;
127128
percentile?: number;

src/features.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,12 @@ export function isProFeature(feature: PlusFeatures): feature is ProFeatures {
103103

104104
export function isAdvancedFeature(feature: PlusFeatures): feature is AdvancedFeatures {
105105
switch (feature) {
106+
case 'explain-changes':
106107
case 'generate-changelog':
108+
case 'generate-create-cloudPatch':
109+
case 'generate-create-codeSuggestion':
107110
case 'generate-create-pullRequest':
111+
case 'generate-rebase':
108112
return true;
109113
default:
110114
return false;
@@ -116,9 +120,6 @@ export function isProFeatureOnAllRepos(feature: PlusFeatures): feature is ProFea
116120
case 'launchpad':
117121
case 'startWork':
118122
case 'associateIssueWithBranch':
119-
case 'explain-changes':
120-
case 'generate-create-cloudPatch':
121-
case 'generate-create-codeSuggestion':
122123
case 'generate-stashMessage':
123124
return true;
124125
default:

src/plus/ai/aiProviderService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,8 @@ export class AIProviderService implements Disposable {
493493
!(await ensureFeatureAccess(
494494
this.container,
495495
isAdvancedFeature(feature)
496-
? `Advanced AI features require a trial or GitLens Advanced.`
497-
: `Pro AI features require a trial or GitLens Pro.`,
496+
? 'This AI feature requires GitLens Advanced or a Pro trial'
497+
: 'This AI feature requires GitLens Pro or a Pro trial',
498498
feature,
499499
source,
500500
))

src/plus/gk/models/promo.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import type { GlCommands } from '../../../constants.commands';
22
import type { SubscriptionState } from '../../../constants.subscription';
3+
import type { PaidSubscriptionPlanIds } from './subscription';
34

4-
export type PromoLocation = 'account' | 'badge' | 'gate' | 'home';
55
export type PromoKeys = 'pro50' | (string & {});
6+
export type PromoLocation = 'account' | 'badge' | 'gate' | 'home';
7+
export type PromoPlans = PaidSubscriptionPlanIds;
68

79
export interface Promo {
810
readonly key: PromoKeys;
911
readonly code?: string;
12+
readonly plan: PromoPlans;
1013
readonly states?: SubscriptionState[];
1114
readonly expiresOn?: number;
1215
readonly startsOn?: number;
1316

1417
readonly locations?: PromoLocation[];
1518
readonly content?: {
19+
readonly modal: { readonly detail: string };
1620
readonly quickpick: { readonly detail: string };
1721
readonly webview?: {
1822
readonly info?: {

src/plus/gk/models/subscription.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export interface SubscriptionAccount {
4949
}
5050

5151
export interface SubscriptionUpgradeCommandArgs extends Source {
52-
plan?: SubscriptionPlanIds;
52+
plan?: PaidSubscriptionPlanIds;
5353
}
5454

5555
export type SubscriptionStateString =

src/plus/gk/productConfigProvider.ts

Lines changed: 100 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,26 @@ import { getLoggableName, Logger } from '../../system/logger';
88
import { startLogScope } from '../../system/logger.scope';
99
import type { Validator } from '../../system/validation';
1010
import { createValidator, Is } from '../../system/validation';
11-
import type { Promo, PromoLocation } from './models/promo';
11+
import type { Promo, PromoLocation, PromoPlans } from './models/promo';
1212
import type { ServerConnection } from './serverConnection';
1313

1414
type Config = {
1515
promos: Promo[];
1616
};
1717

1818
type ConfigJson = {
19-
v: number;
20-
promos: PromoJson[];
19+
/** @deprecated this doesn't provide value, but we need to keep it for old clients */
20+
v?: number;
21+
promos?: PromoJson[];
22+
promosV2?: PromoV2Json[];
2123
};
22-
type PromoJson = Replace<Promo, 'expiresOn' | 'startsOn', string | undefined>;
24+
type PromoJson = Replace<Promo, 'plan' | 'expiresOn' | 'startsOn', string | undefined> & {
25+
v?: number;
26+
plan?: PromoPlans;
27+
};
28+
type PromoV2Json = Replace<Promo, 'expiresOn' | 'startsOn', string | undefined> & { v: number | undefined };
29+
30+
const maxKnownPromoVersion = 2;
2331

2432
export class ProductConfigProvider {
2533
private readonly _lazyConfig: Lazy<Promise<Config>>;
@@ -35,32 +43,25 @@ export class ProductConfigProvider {
3543
statusCode: undefined as number | undefined,
3644
};
3745

46+
if (DEBUG) {
47+
try {
48+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- using @ts-ignore instead of @ts-expect-error because if `product.json` is found then @ts-expect-error will complain because its not an error anymore
49+
// @ts-ignore
50+
const data = (await import('../../../product.json', { with: { type: 'json' } })).default;
51+
const config = getConfig(data);
52+
if (config != null) return config;
53+
54+
debugger;
55+
} catch {}
56+
}
57+
3858
try {
3959
const rsp = await connection.fetchGkConfig('product.json');
4060
if (rsp.ok) {
4161
data = await rsp.json();
4262

43-
const validator = createConfigValidator();
44-
if (validator(data)) {
45-
const promos = data.promos.map(
46-
d =>
47-
({
48-
key: d.key,
49-
code: d.code,
50-
states: d.states,
51-
expiresOn: d.expiresOn == null ? undefined : new Date(d.expiresOn).getTime(),
52-
startsOn: d.startsOn == null ? undefined : new Date(d.startsOn).getTime(),
53-
locations: d.locations,
54-
content: d.content,
55-
percentile: d.percentile,
56-
}) satisfies Promo,
57-
);
58-
59-
const config: Config = { promos: promos };
60-
await container.storage.store('product:config', { data: config, v: 1, timestamp: Date.now() });
61-
62-
return config;
63-
}
63+
const config = getConfig(data);
64+
if (config != null) return config;
6465

6566
failed.validation = true;
6667
} else {
@@ -80,13 +81,19 @@ export class ProductConfigProvider {
8081
});
8182

8283
const stored = container.storage.get('product:config');
83-
if (stored?.data != null) return stored.data;
84+
if (stored?.data != null) {
85+
return {
86+
...stored.data,
87+
promos: stored.data.promos.map(p => ({ ...p, plan: p.plan ?? 'pro' }) satisfies Promo),
88+
} satisfies Config;
89+
}
8490

8591
// If all else fails, return a default set of promos
8692
return {
8793
promos: [
8894
{
8995
key: 'pro50',
96+
plan: 'pro',
9097
states: [
9198
SubscriptionState.Community,
9299
SubscriptionState.Trial,
@@ -95,13 +102,10 @@ export class ProductConfigProvider {
95102
],
96103
locations: ['home', 'account', 'badge', 'gate'],
97104
content: {
98-
quickpick: {
99-
detail: '$(star-full) Save 50% on GitLens Pro',
100-
},
105+
modal: { detail: 'Save 50% on GitLens Pro' },
106+
quickpick: { detail: '$(star-full) Save 50% on GitLens Pro' },
101107
webview: {
102-
info: {
103-
html: '<b>Save 50%</b> on GitLens Pro',
104-
},
108+
info: { html: '<b>Save 50%</b> on GitLens Pro' },
105109
link: {
106110
html: '<b>Save 50%</b> on GitLens Pro',
107111
title: 'Upgrade now and Save 50% on GitLens Pro',
@@ -114,11 +118,15 @@ export class ProductConfigProvider {
114118
});
115119
}
116120

117-
async getApplicablePromo(state: number | undefined, location?: PromoLocation): Promise<Promo | undefined> {
121+
async getApplicablePromo(
122+
state: SubscriptionState | undefined,
123+
plan: PromoPlans,
124+
location?: PromoLocation,
125+
): Promise<Promo | undefined> {
118126
if (state == null) return undefined;
119127

120128
const promos = await this.getPromos();
121-
return getApplicablePromo(promos, state, location);
129+
return getApplicablePromo(promos, state, plan, location);
122130
}
123131

124132
private getConfig(): Promise<Config> {
@@ -145,6 +153,10 @@ function createConfigValidator(): Validator<ConfigJson> {
145153
SubscriptionState.Paid,
146154
);
147155

156+
const isModal = createValidator({
157+
detail: Is.String,
158+
});
159+
148160
const isQuickPick = createValidator({
149161
detail: Is.String,
150162
});
@@ -168,11 +180,14 @@ function createConfigValidator(): Validator<ConfigJson> {
168180
});
169181

170182
const isContent = createValidator({
183+
modal: isModal,
171184
quickpick: isQuickPick,
172185
webview: Is.Optional(isWebview),
173186
});
174187

175188
const promoValidator = createValidator<PromoJson>({
189+
v: Is.Optional(Is.Number),
190+
plan: Is.Optional(Is.Enum<PromoPlans>('pro', 'advanced', 'teams', 'enterprise')),
176191
key: Is.String,
177192
code: Is.Optional(Is.String),
178193
states: Is.Optional(Is.Array(isState)),
@@ -183,17 +198,36 @@ function createConfigValidator(): Validator<ConfigJson> {
183198
percentile: Is.Optional(Is.Number),
184199
});
185200

186-
return createValidator<ConfigJson>({
201+
const promoV2Validator = createValidator<PromoV2Json>({
187202
v: Is.Number,
188-
promos: Is.Array(promoValidator),
203+
key: Is.String,
204+
code: Is.Optional(Is.String),
205+
plan: Is.Enum<PromoPlans>('pro', 'advanced', 'teams', 'enterprise'),
206+
states: Is.Optional(Is.Array(isState)),
207+
expiresOn: Is.Optional(Is.String),
208+
startsOn: Is.Optional(Is.String),
209+
locations: Is.Optional(Is.Array(isLocation)),
210+
content: Is.Optional(isContent),
211+
percentile: Is.Optional(Is.Number),
212+
});
213+
214+
return createValidator<ConfigJson>({
215+
v: Is.Optional(Is.Number),
216+
promos: Is.Optional(Is.Array(promoValidator)),
217+
promosV2: Is.Optional(Is.Array(promoV2Validator)),
189218
});
190219
}
191220

192-
function getApplicablePromo(promos: Promo[], state: number | undefined, location?: PromoLocation): Promo | undefined {
221+
function getApplicablePromo(
222+
promos: Promo[],
223+
state: SubscriptionState | undefined,
224+
plan: PromoPlans,
225+
location?: PromoLocation,
226+
): Promo | undefined {
193227
if (state == null) return undefined;
194228

195229
for (const promo of promos) {
196-
if (isPromoApplicable(promo, state)) {
230+
if (isPromoApplicable(promo, state, plan)) {
197231
if (location == null || promo.locations == null || promo.locations.includes(location)) {
198232
return promo;
199233
}
@@ -204,10 +238,37 @@ function getApplicablePromo(promos: Promo[], state: number | undefined, location
204238
return undefined;
205239
}
206240

207-
function isPromoApplicable(promo: Promo, state: number): boolean {
241+
function getConfig(data: unknown): Config | undefined {
242+
const validator = createConfigValidator();
243+
if (!validator(data)) return undefined;
244+
245+
const promos = (data.promosV2 ?? data.promos ?? [])
246+
// Filter out promos that we don't know how to handle
247+
.filter(d => d.v == null || d.v <= maxKnownPromoVersion)
248+
.map(
249+
d =>
250+
({
251+
key: d.key,
252+
code: d.code,
253+
plan: d.plan ?? 'pro',
254+
states: d.states,
255+
expiresOn: d.expiresOn == null ? undefined : new Date(d.expiresOn).getTime(),
256+
startsOn: d.startsOn == null ? undefined : new Date(d.startsOn).getTime(),
257+
locations: d.locations,
258+
content: d.content,
259+
percentile: d.percentile,
260+
}) satisfies Promo,
261+
);
262+
263+
const config: Config = { promos: promos };
264+
return config;
265+
}
266+
267+
function isPromoApplicable(promo: Promo, state: SubscriptionState, plan: PromoPlans): boolean {
208268
const now = Date.now();
209269

210270
return (
271+
(promo.plan == null || promo.plan === plan) &&
211272
(promo.states == null || promo.states.includes(state)) &&
212273
(promo.expiresOn == null || promo.expiresOn > now) &&
213274
(promo.startsOn == null || promo.startsOn < now) &&

0 commit comments

Comments
 (0)