1+ import fetch from 'node-fetch' ;
12import type { PromoKeys } from '../../../constants.subscription' ;
23import { SubscriptionState } from '../../../constants.subscription' ;
4+ import { wait } from '../../../system/promise' ;
5+ import { pickApplicablePromo } from './promosTools' ;
36
47export 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 ) ;
0 commit comments