Skip to content

Commit 0817063

Browse files
committed
Loads promo.json from an s3 bucket
1 parent 5abb804 commit 0817063

File tree

14 files changed

+299
-86
lines changed

14 files changed

+299
-86
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2652,7 +2652,7 @@ export async function* ensureAccessStep<
26522652
} else {
26532653
if (access.subscription.required == null) return access;
26542654

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

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

src/plus/gk/account/promos.ts

Lines changed: 180 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import fetch from 'node-fetch';
12
import type { PromoKeys } from '../../../constants.subscription';
23
import { SubscriptionState } from '../../../constants.subscription';
4+
import { wait } from '../../../system/promise';
5+
import { pickApplicablePromo } from './promosTools';
36

47
export type PromoLocation = 'account' | 'badge' | 'gate' | 'home';
58

@@ -18,69 +21,190 @@ export interface Promo {
1821
readonly quickpick: { detail: string };
1922
}
2023

21-
// Must be ordered by applicable order
22-
const promos: Promo[] = [
23-
{
24-
key: 'gkholiday',
25-
code: 'GKHOLIDAY',
26-
states: [
27-
SubscriptionState.Community,
28-
SubscriptionState.ProPreview,
29-
SubscriptionState.ProPreviewExpired,
30-
SubscriptionState.ProTrial,
31-
SubscriptionState.ProTrialExpired,
32-
SubscriptionState.ProTrialReactivationEligible,
33-
],
34-
startsOn: new Date('2024-12-09T06:59:00.000Z').getTime(),
35-
expiresOn: new Date('2025-01-07T06:59:00.000Z').getTime(),
36-
command: { tooltip: 'Get the gift of a better DevEx in 2025! Save up to 80% now' },
37-
quickpick: {
38-
detail: '$(star-full) Get the gift of a better DevEx in 2025! Save up to 80% now',
39-
},
40-
},
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,
4132
{
42-
key: 'pro50',
43-
states: [
44-
SubscriptionState.Community,
45-
SubscriptionState.ProPreview,
46-
SubscriptionState.ProPreviewExpired,
47-
SubscriptionState.ProTrial,
48-
SubscriptionState.ProTrialExpired,
49-
SubscriptionState.ProTrialReactivationEligible,
50-
],
51-
command: { tooltip: 'Save 33% or more on your 1st seat of Pro.' },
52-
locations: ['account', 'badge', 'gate'],
53-
quickpick: {
54-
detail: '$(star-full) Save 33% or more on your 1st seat of Pro',
55-
},
56-
},
57-
];
33+
startsOn?: string;
34+
expiresOn?: string;
35+
states?: string[];
36+
}
37+
>;
5838

59-
export function getApplicablePromo(
60-
state: number | undefined,
61-
location?: PromoLocation,
62-
key?: PromoKeys,
63-
): Promo | undefined {
64-
if (state == null) return undefined;
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+
}
65171

66-
for (const promo of promos) {
67-
if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) {
68-
if (location == null || promo.locations == null || promo.locations.includes(location)) {
69-
return promo;
172+
async initialize() {
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');
70181
}
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+
}
71188

72-
break;
189+
async getPromoList() {
190+
try {
191+
await this.waitForFirstRefreshInitialized();
192+
return this._promo!;
193+
} catch {
194+
return undefined;
73195
}
74196
}
75197

76-
return undefined;
198+
async getApplicablePromo(state: number | undefined, location?: PromoLocation, key?: PromoKeys) {
199+
try {
200+
await this.waitForFirstRefreshInitialized();
201+
return pickApplicablePromo(this._promo, state, location, key);
202+
} catch {
203+
return undefined;
204+
}
205+
}
77206
}
78207

79-
function isPromoApplicable(promo: Promo, state: number): boolean {
80-
const now = Date.now();
81-
return (
82-
(promo.states == null || promo.states.includes(state)) &&
83-
(promo.expiresOn == null || promo.expiresOn > now) &&
84-
(promo.startsOn == null || promo.startsOn < now)
85-
);
86-
}
208+
export const promoProvider = new PromoProvider();
209+
210+
export const getApplicablePromo = promoProvider.getApplicablePromo.bind(promoProvider);

src/plus/gk/account/promosTools.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { PromoKeys, SubscriptionState } from '../../../constants.subscription';
2+
import type { Promo, PromoLocation } from './promos';
3+
4+
export const pickApplicablePromo = (
5+
promoList: Promo[] | undefined,
6+
subscriptionState: SubscriptionState | undefined,
7+
location?: PromoLocation,
8+
key?: PromoKeys,
9+
) => {
10+
if (subscriptionState == null || !promoList) return undefined;
11+
12+
for (const promo of promoList) {
13+
if ((key == null || key === promo.key) && isPromoApplicable(promo, subscriptionState)) {
14+
if (location == null || promo.locations == null || promo.locations.includes(location)) {
15+
return promo;
16+
}
17+
18+
break;
19+
}
20+
}
21+
22+
return undefined;
23+
};
24+
export function isPromoApplicable(promo: Promo, state: number): boolean {
25+
const now = Date.now();
26+
return (
27+
(promo.states == null || promo.states.includes(state)) &&
28+
(promo.expiresOn == null || promo.expiresOn > now) &&
29+
(promo.startsOn == null || promo.startsOn < now)
30+
);
31+
}

src/plus/gk/account/subscriptionService.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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/webviews/apps/home/components/promo-banner.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +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 { getApplicablePromo } from '../../../../plus/gk/account/promos';
4+
import type { Promo } from '../../../../plus/gk/account/promos';
55
import type { State } from '../../../home/protocol';
6-
import { stateContext } from '../context';
76
import '../../shared/components/promo';
7+
import { promoContext } from '../../shared/context';
8+
import { stateContext } from '../context';
89

910
@customElement('gl-promo-banner')
1011
export class GlPromoBanner extends LitElement {
@@ -32,21 +33,25 @@ export class GlPromoBanner extends LitElement {
3233
private _state!: State;
3334

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

39-
get promo() {
40-
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;
4145
}
4246

4347
override render() {
44-
if (!this.promo) {
48+
const promo = this.getPromo();
49+
if (!promo) {
4550
return nothing;
4651
}
4752

4853
return html`
49-
<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>
5055
`;
5156
}
5257
}

0 commit comments

Comments
 (0)