Skip to content

Commit fd1215a

Browse files
committed
Loads promo.json from an s3 bucket
1 parent 06f8cf2 commit fd1215a

File tree

16 files changed

+318
-68
lines changed

16 files changed

+318
-68
lines changed

src/@types/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export declare global {
22
declare const DEBUG: boolean;
3+
declare const GL_PROMO_URI: string | undefined;
34

45
export type PartialDeep<T> = T extends Record<string, unknown> ? { [K in keyof T]?: PartialDeep<T[K]> } : T;
56
export type Optional<T, K extends keyof T> = Omit<T, K> & { [P in K]?: T[P] };

src/commands/quickCommand.steps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
} from '../git/utils/reference.utils';
4646
import { getHighlanderProviderName } from '../git/utils/remote.utils';
4747
import { createRevisionRange, isRevisionRange } from '../git/utils/revision.utils';
48-
import { getApplicablePromo } from '../plus/gk/utils/promo.utils';
48+
import { getApplicablePromo } from '../plus/gk/account/promos';
4949
import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/utils/subscription.utils';
5050
import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad';
5151
import {
@@ -2651,7 +2651,7 @@ export async function* ensureAccessStep<
26512651
} else {
26522652
if (access.subscription.required == null) return access;
26532653

2654-
const promo = getApplicablePromo(access.subscription.current.state, 'gate');
2654+
const promo = await getApplicablePromo(access.subscription.current.state, 'gate');
26552655
const detail = promo?.quickpick.detail;
26562656

26572657
placeholder = 'Pro feature — requires a trial or GitLens Pro for use on privately-hosted repos';

src/constants.promos.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1 @@
1-
import { SubscriptionState } from './constants.subscription';
2-
import type { Promo } from './plus/gk/models/promo';
3-
4-
export type PromoKeys = 'pro50';
5-
6-
// Must be ordered by applicable order
7-
export const promos: Promo[] = [
8-
{
9-
key: 'pro50',
10-
states: [
11-
SubscriptionState.Community,
12-
SubscriptionState.ProPreview,
13-
SubscriptionState.ProPreviewExpired,
14-
SubscriptionState.ProTrial,
15-
SubscriptionState.ProTrialExpired,
16-
SubscriptionState.ProTrialReactivationEligible,
17-
],
18-
command: { tooltip: 'Save 55% or more on your 1st seat of Pro.' },
19-
locations: ['account', 'badge', 'gate'],
20-
quickpick: {
21-
detail: '$(star-full) Save 55% or more on your 1st seat of Pro',
22-
},
23-
},
24-
];
1+
export type PromoKeys = 'pro50' | 'gkholiday';

src/plus/gk/account/promos.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import fetch from 'node-fetch';
2+
import type { PromoKeys } from '../../../constants.promos';
3+
import { SubscriptionState } from '../../../constants.subscription';
4+
import { wait } from '../../../system/promise';
5+
import { pickApplicablePromo } from '../utils/promo.utils';
6+
7+
export type PromoLocation = 'account' | 'badge' | 'gate' | 'home';
8+
9+
export interface Promo {
10+
readonly key: PromoKeys;
11+
readonly code?: string;
12+
readonly states?: SubscriptionState[];
13+
readonly expiresOn?: number;
14+
readonly startsOn?: number;
15+
16+
readonly command?: {
17+
command?: `command:${string}`;
18+
tooltip: string;
19+
};
20+
readonly locations?: PromoLocation[];
21+
readonly quickpick: { detail: string };
22+
}
23+
24+
function isValidDate(d: Date) {
25+
// @ts-expect-error isNaN expects number, but works with Date instance
26+
return d instanceof Date && !isNaN(d);
27+
}
28+
29+
type Modify<T, R> = Omit<T, keyof R> & R;
30+
type SerializedPromo = Modify<
31+
Promo,
32+
{
33+
startsOn?: string;
34+
expiresOn?: string;
35+
states?: string[];
36+
}
37+
>;
38+
39+
function deserializePromo(input: object): Promo[] {
40+
try {
41+
const object = input as Array<SerializedPromo>;
42+
const validPromos: Array<Promo> = [];
43+
if (typeof object !== 'object' || !Array.isArray(object)) {
44+
throw new Error('deserializePromo: input is not array');
45+
}
46+
const allowedPromoKeys: Record<PromoKeys, boolean> = { gkholiday: true, pro50: true };
47+
for (const promoItem of object) {
48+
let states: SubscriptionState[] | undefined = undefined;
49+
let locations: PromoLocation[] | undefined = undefined;
50+
if (!promoItem.key || !allowedPromoKeys[promoItem.key]) {
51+
console.warn('deserializePromo: promo item with no id detected and skipped');
52+
continue;
53+
}
54+
if (!promoItem.quickpick?.detail) {
55+
console.warn(
56+
`deserializePromo: no detail provided for promo with key ${promoItem.key} detected and skipped`,
57+
);
58+
continue;
59+
}
60+
if (promoItem.states && !Array.isArray(promoItem.states)) {
61+
console.warn(
62+
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect states value`,
63+
);
64+
continue;
65+
}
66+
if (promoItem.states) {
67+
states = [];
68+
for (const state of promoItem.states) {
69+
// @ts-expect-error unsafe work with enum object
70+
if (Object.hasOwn(SubscriptionState, state)) {
71+
// @ts-expect-error unsafe work with enum object
72+
states.push(SubscriptionState[state]);
73+
} else {
74+
console.warn(
75+
`deserializePromo: invalid state value "${state}" detected and skipped at promo with key ${promoItem.key}`,
76+
);
77+
}
78+
}
79+
}
80+
if (promoItem.locations && !Array.isArray(promoItem.locations)) {
81+
console.warn(
82+
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect locations value`,
83+
);
84+
continue;
85+
}
86+
if (promoItem.locations) {
87+
locations = [];
88+
const allowedLocations: Record<PromoLocation, true> = {
89+
account: true,
90+
badge: true,
91+
gate: true,
92+
home: true,
93+
};
94+
for (const location of promoItem.locations) {
95+
if (allowedLocations[location]) {
96+
locations.push(location);
97+
} else {
98+
console.warn(
99+
`deserializePromo: invalid location value "${location}" detected and skipped at promo with key ${promoItem.key}`,
100+
);
101+
}
102+
}
103+
}
104+
if (promoItem.code && typeof promoItem.code !== 'string') {
105+
console.warn(
106+
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect code value`,
107+
);
108+
continue;
109+
}
110+
if (
111+
promoItem.command &&
112+
(typeof promoItem.command.tooltip !== 'string' ||
113+
(promoItem.command.command && typeof promoItem.command.command !== 'string'))
114+
) {
115+
console.warn(
116+
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect code value`,
117+
);
118+
continue;
119+
}
120+
if (
121+
promoItem.expiresOn &&
122+
(typeof promoItem.expiresOn !== 'string' || !isValidDate(new Date(promoItem.expiresOn)))
123+
) {
124+
console.warn(
125+
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect expiresOn value: ISO date string is expected`,
126+
);
127+
continue;
128+
}
129+
if (
130+
promoItem.startsOn &&
131+
(typeof promoItem.startsOn !== 'string' || !isValidDate(new Date(promoItem.startsOn)))
132+
) {
133+
console.warn(
134+
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect startsOn value: ISO date string is expected`,
135+
);
136+
continue;
137+
}
138+
validPromos.push({
139+
...promoItem,
140+
expiresOn: promoItem.expiresOn ? new Date(promoItem.expiresOn).getTime() : undefined,
141+
startsOn: promoItem.startsOn ? new Date(promoItem.startsOn).getTime() : undefined,
142+
states: states,
143+
locations: locations,
144+
});
145+
}
146+
return validPromos;
147+
} catch (e) {
148+
throw new Error(`deserializePromo: Could not deserialize promo: ${e.message ?? e}`);
149+
}
150+
}
151+
152+
export class PromoProvider {
153+
private _isInitialized: boolean = false;
154+
private _initPromise: Promise<void> | undefined;
155+
private _promo: Array<Promo> | undefined;
156+
constructor() {
157+
void this.waitForFirstRefreshInitialized();
158+
}
159+
160+
private async waitForFirstRefreshInitialized() {
161+
if (this._isInitialized) {
162+
return;
163+
}
164+
if (!this._initPromise) {
165+
this._initPromise = this.initialize().then(() => {
166+
this._isInitialized = true;
167+
});
168+
}
169+
await this._initPromise;
170+
}
171+
172+
async initialize(): Promise<void> {
173+
await wait(1000);
174+
if (this._isInitialized) {
175+
return;
176+
}
177+
try {
178+
console.log('PromoProvider GL_PROMO_URI', GL_PROMO_URI);
179+
if (!GL_PROMO_URI) {
180+
throw new Error('No GL_PROMO_URI env variable provided');
181+
}
182+
const jsonBody = JSON.parse(await fetch(GL_PROMO_URI).then(x => x.text()));
183+
this._promo = deserializePromo(jsonBody);
184+
} catch (e) {
185+
console.error('PromoProvider error', e);
186+
}
187+
}
188+
189+
async getPromoList(): Promise<Promo[] | undefined> {
190+
try {
191+
await this.waitForFirstRefreshInitialized();
192+
return this._promo!;
193+
} catch {
194+
return undefined;
195+
}
196+
}
197+
198+
async getApplicablePromo(
199+
state: number | undefined,
200+
location?: PromoLocation,
201+
key?: PromoKeys,
202+
): Promise<Promo | undefined> {
203+
try {
204+
await this.waitForFirstRefreshInitialized();
205+
return pickApplicablePromo(this._promo, state, location, key);
206+
} catch {
207+
return undefined;
208+
}
209+
}
210+
}
211+
212+
export const promoProvider = new PromoProvider();
213+
214+
export const getApplicablePromo = promoProvider.getApplicablePromo.bind(promoProvider);

src/plus/gk/subscriptionService.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { flatten } from '../../system/object';
6363
import { pauseOnCancelOrTimeout } from '../../system/promise';
6464
import { pluralize } from '../../system/string';
6565
import { satisfies } from '../../system/version';
66+
import { getApplicablePromo } from './account/promos';
6667
import { LoginUriPathPrefix } from './authenticationConnection';
6768
import { authenticationProviderScopes } from './authenticationProvider';
6869
import type { GKCheckInResponse } from './models/checkin';
@@ -71,7 +72,6 @@ import type { Subscription } from './models/subscription';
7172
import type { ServerConnection } from './serverConnection';
7273
import { ensurePlusFeaturesEnabled } from './utils/-webview/plus.utils';
7374
import { getSubscriptionFromCheckIn } from './utils/checkin.utils';
74-
import { getApplicablePromo } from './utils/promo.utils';
7575
import {
7676
assertSubscriptionState,
7777
computeSubscriptionState,
@@ -893,7 +893,7 @@ export class SubscriptionService implements Disposable {
893893

894894
const hasAccount = this._subscription.account != null;
895895

896-
const promoCode = getApplicablePromo(this._subscription.state)?.code;
896+
const promoCode = (await getApplicablePromo(this._subscription.state))?.code;
897897
if (promoCode != null) {
898898
query.set('promoCode', promoCode);
899899
}
@@ -1375,8 +1375,9 @@ export class SubscriptionService implements Disposable {
13751375
subscription.state = computeSubscriptionState(subscription);
13761376
assertSubscriptionState(subscription);
13771377

1378-
const promo = getApplicablePromo(subscription.state);
1379-
void setContext('gitlens:promo', promo?.key);
1378+
void getApplicablePromo(subscription.state).then(promo => {
1379+
void setContext('gitlens:promo', promo?.key);
1380+
});
13801381

13811382
const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor
13821383
// Check the previous and new subscriptions are exactly the same

src/plus/gk/utils/promo.utils.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { PromoKeys } from '../../../constants.promos';
2-
import { promos } from '../../../constants.promos';
2+
import type { SubscriptionState } from '../../../constants.subscription';
33
import type { Promo, PromoLocation } from '../models/promo';
44

5-
export function getApplicablePromo(
6-
state: number | undefined,
5+
export const pickApplicablePromo = (
6+
promoList: Promo[] | undefined,
7+
subscriptionState: SubscriptionState | undefined,
78
location?: PromoLocation,
89
key?: PromoKeys,
9-
): Promo | undefined {
10-
if (state == null) return undefined;
10+
): Promo | undefined => {
11+
if (subscriptionState == null || !promoList) return undefined;
1112

12-
for (const promo of promos) {
13-
if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) {
13+
for (const promo of promoList) {
14+
if ((key == null || key === promo.key) && isPromoApplicable(promo, subscriptionState)) {
1415
if (location == null || promo.locations == null || promo.locations.includes(location)) {
1516
return promo;
1617
}
@@ -20,7 +21,7 @@ export function getApplicablePromo(
2021
}
2122

2223
return undefined;
23-
}
24+
};
2425

2526
function isPromoApplicable(promo: Promo, state: number): boolean {
2627
const now = Date.now();

src/webviews/apps/home/components/promo-banner.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { consume } from '@lit/context';
22
import { css, html, LitElement, nothing } from 'lit';
33
import { customElement, property, state } from 'lit/decorators.js';
4-
import type { Promo } from '../../../../plus/gk/models/promo';
5-
import { getApplicablePromo } from '../../../../plus/gk/utils/promo.utils';
4+
import type { Promo } from '../../../../plus/gk/account/promos';
65
import type { State } from '../../../home/protocol';
7-
import { stateContext } from '../context';
86
import '../../shared/components/promo';
7+
import { promoContext } from '../../shared/context';
8+
import { stateContext } from '../context';
99

1010
@customElement('gl-promo-banner')
1111
export class GlPromoBanner extends LitElement {
@@ -33,21 +33,25 @@ export class GlPromoBanner extends LitElement {
3333
private _state!: State;
3434

3535
@property({ type: Boolean, reflect: true, attribute: 'has-promo' })
36-
get hasPromos(): boolean | undefined {
37-
return this.promo == null ? undefined : true;
38-
}
36+
hasPromos?: boolean;
37+
38+
@consume({ context: promoContext, subscribe: true })
39+
private readonly getApplicablePromo!: typeof promoContext.__context__;
3940

40-
get promo(): Promo | undefined {
41-
return getApplicablePromo(this._state.subscription.state, 'home');
41+
getPromo(): Promo | undefined {
42+
const promo = this.getApplicablePromo(this._state.subscription.state, 'home');
43+
this.hasPromos = promo == null ? undefined : true;
44+
return promo;
4245
}
4346

44-
override render(): unknown {
45-
if (!this.promo) {
47+
override render() {
48+
const promo = this.getPromo();
49+
if (!promo) {
4650
return nothing;
4751
}
4852

4953
return html`
50-
<gl-promo .promo=${this.promo} class="promo-banner promo-banner--eyebrow" id="promo" type="link"></gl-promo>
54+
<gl-promo .promo=${promo} class="promo-banner promo-banner--eyebrow" id="promo" type="link"></gl-promo>
5155
`;
5256
}
5357
}

0 commit comments

Comments
 (0)