Skip to content

Commit

Permalink
Loads promo.json from an s3 bucket
Browse files Browse the repository at this point in the history
  • Loading branch information
nzaytsev committed Jan 13, 2025
1 parent 5abb804 commit 0817063
Show file tree
Hide file tree
Showing 14 changed files with 299 additions and 86 deletions.
1 change: 1 addition & 0 deletions src/@types/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export declare global {
declare const DEBUG: boolean;
declare const GL_PROMO_URI: string | undefined;

export type PartialDeep<T> = T extends Record<string, unknown> ? { [K in keyof T]?: PartialDeep<T[K]> } : T;
export type Optional<T, K extends keyof T> = Omit<T, K> & { [P in K]?: T[P] };
Expand Down
2 changes: 1 addition & 1 deletion src/commands/quickCommand.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2652,7 +2652,7 @@ export async function* ensureAccessStep<
} else {
if (access.subscription.required == null) return access;

const promo = getApplicablePromo(access.subscription.current.state, 'gate');
const promo = await getApplicablePromo(access.subscription.current.state, 'gate');
const detail = promo?.quickpick.detail;

placeholder = 'Pro feature — requires a trial or GitLens Pro for use on privately-hosted repos';
Expand Down
236 changes: 180 additions & 56 deletions src/plus/gk/account/promos.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import fetch from 'node-fetch';
import type { PromoKeys } from '../../../constants.subscription';
import { SubscriptionState } from '../../../constants.subscription';
import { wait } from '../../../system/promise';
import { pickApplicablePromo } from './promosTools';

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

Expand All @@ -18,69 +21,190 @@ export interface Promo {
readonly quickpick: { detail: string };
}

// Must be ordered by applicable order
const promos: Promo[] = [
{
key: 'gkholiday',
code: 'GKHOLIDAY',
states: [
SubscriptionState.Community,
SubscriptionState.ProPreview,
SubscriptionState.ProPreviewExpired,
SubscriptionState.ProTrial,
SubscriptionState.ProTrialExpired,
SubscriptionState.ProTrialReactivationEligible,
],
startsOn: new Date('2024-12-09T06:59:00.000Z').getTime(),
expiresOn: new Date('2025-01-07T06:59:00.000Z').getTime(),
command: { tooltip: 'Get the gift of a better DevEx in 2025! Save up to 80% now' },
quickpick: {
detail: '$(star-full) Get the gift of a better DevEx in 2025! Save up to 80% now',
},
},
function isValidDate(d: Date) {
// @ts-expect-error isNaN expects number, but works with Date instance
return d instanceof Date && !isNaN(d);
}

type Modify<T, R> = Omit<T, keyof R> & R;
type SerializedPromo = Modify<
Promo,
{
key: 'pro50',
states: [
SubscriptionState.Community,
SubscriptionState.ProPreview,
SubscriptionState.ProPreviewExpired,
SubscriptionState.ProTrial,
SubscriptionState.ProTrialExpired,
SubscriptionState.ProTrialReactivationEligible,
],
command: { tooltip: 'Save 33% or more on your 1st seat of Pro.' },
locations: ['account', 'badge', 'gate'],
quickpick: {
detail: '$(star-full) Save 33% or more on your 1st seat of Pro',
},
},
];
startsOn?: string;
expiresOn?: string;
states?: string[];
}
>;

export function getApplicablePromo(
state: number | undefined,
location?: PromoLocation,
key?: PromoKeys,
): Promo | undefined {
if (state == null) return undefined;
function deserializePromo(input: object): Promo[] {
try {
const object = input as Array<SerializedPromo>;
const validPromos: Array<Promo> = [];
if (typeof object !== 'object' || !Array.isArray(object)) {
throw new Error('deserializePromo: input is not array');
}
const allowedPromoKeys: Record<PromoKeys, boolean> = { gkholiday: true, pro50: true };
for (const promoItem of object) {
let states: SubscriptionState[] | undefined = undefined;
let locations: PromoLocation[] | undefined = undefined;
if (!promoItem.key || !allowedPromoKeys[promoItem.key]) {
console.warn('deserializePromo: promo item with no id detected and skipped');
continue;
}
if (!promoItem.quickpick?.detail) {
console.warn(
`deserializePromo: no detail provided for promo with key ${promoItem.key} detected and skipped`,
);
continue;
}
if (promoItem.states && !Array.isArray(promoItem.states)) {
console.warn(
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect states value`,
);
continue;
}
if (promoItem.states) {
states = [];
for (const state of promoItem.states) {
// @ts-expect-error unsafe work with enum object
if (Object.hasOwn(SubscriptionState, state)) {
// @ts-expect-error unsafe work with enum object
states.push(SubscriptionState[state]);
} else {
console.warn(
`deserializePromo: invalid state value "${state}" detected and skipped at promo with key ${promoItem.key}`,
);
}
}
}
if (promoItem.locations && !Array.isArray(promoItem.locations)) {
console.warn(
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect locations value`,
);
continue;
}
if (promoItem.locations) {
locations = [];
const allowedLocations: Record<PromoLocation, true> = {
account: true,
badge: true,
gate: true,
home: true,
};
for (const location of promoItem.locations) {
if (allowedLocations[location]) {
locations.push(location);
} else {
console.warn(
`deserializePromo: invalid location value "${location}" detected and skipped at promo with key ${promoItem.key}`,
);
}
}
}
if (promoItem.code && typeof promoItem.code !== 'string') {
console.warn(
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect code value`,
);
continue;
}
if (
promoItem.command &&
(typeof promoItem.command.tooltip !== 'string' ||
(promoItem.command.command && typeof promoItem.command.command !== 'string'))
) {
console.warn(
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect code value`,
);
continue;
}
if (
promoItem.expiresOn &&
(typeof promoItem.expiresOn !== 'string' || !isValidDate(new Date(promoItem.expiresOn)))
) {
console.warn(
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect expiresOn value: ISO date string is expected`,
);
continue;
}
if (
promoItem.startsOn &&
(typeof promoItem.startsOn !== 'string' || !isValidDate(new Date(promoItem.startsOn)))
) {
console.warn(
`deserializePromo: promo with key ${promoItem.key} is skipped because of incorrect startsOn value: ISO date string is expected`,
);
continue;
}
validPromos.push({
...promoItem,
expiresOn: promoItem.expiresOn ? new Date(promoItem.expiresOn).getTime() : undefined,
startsOn: promoItem.startsOn ? new Date(promoItem.startsOn).getTime() : undefined,
states: states,
locations: locations,
});
}
return validPromos;
} catch (e) {
throw new Error(`deserializePromo: Could not deserialize promo: ${e.message ?? e}`);
}
}

export class PromoProvider {
private _isInitialized: boolean = false;
private _initPromise: Promise<void> | undefined;
private _promo: Array<Promo> | undefined;
constructor() {
void this.waitForFirstRefreshInitialized();
}

private async waitForFirstRefreshInitialized() {
if (this._isInitialized) {
return;
}
if (!this._initPromise) {
this._initPromise = this.initialize().then(() => {
this._isInitialized = true;
});
}
await this._initPromise;
}

for (const promo of promos) {
if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) {
if (location == null || promo.locations == null || promo.locations.includes(location)) {
return promo;
async initialize() {
await wait(1000);
if (this._isInitialized) {
return;
}
try {
console.log('PromoProvider GL_PROMO_URI', GL_PROMO_URI);
if (!GL_PROMO_URI) {
throw new Error('No GL_PROMO_URI env variable provided');
}
const jsonBody = JSON.parse(await fetch(GL_PROMO_URI).then(x => x.text()));
this._promo = deserializePromo(jsonBody);
} catch (e) {
console.error('PromoProvider error', e);
}
}

break;
async getPromoList() {
try {
await this.waitForFirstRefreshInitialized();
return this._promo!;
} catch {
return undefined;
}
}

return undefined;
async getApplicablePromo(state: number | undefined, location?: PromoLocation, key?: PromoKeys) {
try {
await this.waitForFirstRefreshInitialized();
return pickApplicablePromo(this._promo, state, location, key);
} catch {
return undefined;
}
}
}

function isPromoApplicable(promo: Promo, state: number): boolean {
const now = Date.now();
return (
(promo.states == null || promo.states.includes(state)) &&
(promo.expiresOn == null || promo.expiresOn > now) &&
(promo.startsOn == null || promo.startsOn < now)
);
}
export const promoProvider = new PromoProvider();

export const getApplicablePromo = promoProvider.getApplicablePromo.bind(promoProvider);
31 changes: 31 additions & 0 deletions src/plus/gk/account/promosTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { PromoKeys, SubscriptionState } from '../../../constants.subscription';
import type { Promo, PromoLocation } from './promos';

export const pickApplicablePromo = (
promoList: Promo[] | undefined,
subscriptionState: SubscriptionState | undefined,
location?: PromoLocation,
key?: PromoKeys,
) => {
if (subscriptionState == null || !promoList) return undefined;

for (const promo of promoList) {
if ((key == null || key === promo.key) && isPromoApplicable(promo, subscriptionState)) {
if (location == null || promo.locations == null || promo.locations.includes(location)) {
return promo;
}

break;
}
}

return undefined;
};
export function isPromoApplicable(promo: Promo, state: number): boolean {
const now = Date.now();
return (
(promo.states == null || promo.states.includes(state)) &&
(promo.expiresOn == null || promo.expiresOn > now) &&
(promo.startsOn == null || promo.startsOn < now)
);
}
7 changes: 4 additions & 3 deletions src/plus/gk/account/subscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ export class SubscriptionService implements Disposable {

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

const promoCode = getApplicablePromo(this._subscription.state)?.code;
const promoCode = (await getApplicablePromo(this._subscription.state))?.code;
if (promoCode != null) {
query.set('promoCode', promoCode);
}
Expand Down Expand Up @@ -1375,8 +1375,9 @@ export class SubscriptionService implements Disposable {
subscription.state = computeSubscriptionState(subscription);
assertSubscriptionState(subscription);

const promo = getApplicablePromo(subscription.state);
void setContext('gitlens:promo', promo?.key);
void getApplicablePromo(subscription.state).then(promo => {
void setContext('gitlens:promo', promo?.key);
});

const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor
// Check the previous and new subscriptions are exactly the same
Expand Down
23 changes: 14 additions & 9 deletions src/webviews/apps/home/components/promo-banner.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { consume } from '@lit/context';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { getApplicablePromo } from '../../../../plus/gk/account/promos';
import type { Promo } from '../../../../plus/gk/account/promos';
import type { State } from '../../../home/protocol';
import { stateContext } from '../context';
import '../../shared/components/promo';
import { promoContext } from '../../shared/context';
import { stateContext } from '../context';

@customElement('gl-promo-banner')
export class GlPromoBanner extends LitElement {
Expand Down Expand Up @@ -32,21 +33,25 @@ export class GlPromoBanner extends LitElement {
private _state!: State;

@property({ type: Boolean, reflect: true, attribute: 'has-promo' })
get hasPromos() {
return this.promo == null ? undefined : true;
}
hasPromos?: boolean;

@consume({ context: promoContext, subscribe: true })
private readonly getApplicablePromo!: typeof promoContext.__context__;

get promo() {
return getApplicablePromo(this._state.subscription.state, 'home');
getPromo(): Promo | undefined {
const promo = this.getApplicablePromo(this._state.subscription.state, 'home');
this.hasPromos = promo == null ? undefined : true;
return promo;
}

override render() {
if (!this.promo) {
const promo = this.getPromo();
if (!promo) {
return nothing;
}

return html`
<gl-promo .promo=${this.promo} class="promo-banner promo-banner--eyebrow" id="promo" type="link"></gl-promo>
<gl-promo .promo=${promo} class="promo-banner promo-banner--eyebrow" id="promo" type="link"></gl-promo>
`;
}
}
Loading

0 comments on commit 0817063

Please sign in to comment.