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 Feb 3, 2025
1 parent 06f8cf2 commit fd1215a
Show file tree
Hide file tree
Showing 16 changed files with 318 additions and 68 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
4 changes: 2 additions & 2 deletions src/commands/quickCommand.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from '../git/utils/reference.utils';
import { getHighlanderProviderName } from '../git/utils/remote.utils';
import { createRevisionRange, isRevisionRange } from '../git/utils/revision.utils';
import { getApplicablePromo } from '../plus/gk/utils/promo.utils';
import { getApplicablePromo } from '../plus/gk/account/promos';
import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/utils/subscription.utils';
import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad';
import {
Expand Down Expand Up @@ -2651,7 +2651,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
25 changes: 1 addition & 24 deletions src/constants.promos.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1 @@
import { SubscriptionState } from './constants.subscription';
import type { Promo } from './plus/gk/models/promo';

export type PromoKeys = 'pro50';

// Must be ordered by applicable order
export const promos: Promo[] = [
{
key: 'pro50',
states: [
SubscriptionState.Community,
SubscriptionState.ProPreview,
SubscriptionState.ProPreviewExpired,
SubscriptionState.ProTrial,
SubscriptionState.ProTrialExpired,
SubscriptionState.ProTrialReactivationEligible,
],
command: { tooltip: 'Save 55% or more on your 1st seat of Pro.' },
locations: ['account', 'badge', 'gate'],
quickpick: {
detail: '$(star-full) Save 55% or more on your 1st seat of Pro',
},
},
];
export type PromoKeys = 'pro50' | 'gkholiday';
214 changes: 214 additions & 0 deletions src/plus/gk/account/promos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import fetch from 'node-fetch';
import type { PromoKeys } from '../../../constants.promos';
import { SubscriptionState } from '../../../constants.subscription';
import { wait } from '../../../system/promise';
import { pickApplicablePromo } from '../utils/promo.utils';

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

export interface Promo {
readonly key: PromoKeys;
readonly code?: string;
readonly states?: SubscriptionState[];
readonly expiresOn?: number;
readonly startsOn?: number;

readonly command?: {
command?: `command:${string}`;
tooltip: string;
};
readonly locations?: PromoLocation[];
readonly quickpick: { detail: string };
}

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,
{
startsOn?: string;
expiresOn?: string;
states?: string[];
}
>;

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;
}

async initialize(): Promise<void> {
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);
}
}

async getPromoList(): Promise<Promo[] | undefined> {
try {
await this.waitForFirstRefreshInitialized();
return this._promo!;
} catch {
return undefined;
}
}

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

export const promoProvider = new PromoProvider();

export const getApplicablePromo = promoProvider.getApplicablePromo.bind(promoProvider);
9 changes: 5 additions & 4 deletions src/plus/gk/subscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { flatten } from '../../system/object';
import { pauseOnCancelOrTimeout } from '../../system/promise';
import { pluralize } from '../../system/string';
import { satisfies } from '../../system/version';
import { getApplicablePromo } from './account/promos';
import { LoginUriPathPrefix } from './authenticationConnection';
import { authenticationProviderScopes } from './authenticationProvider';
import type { GKCheckInResponse } from './models/checkin';
Expand All @@ -71,7 +72,6 @@ import type { Subscription } from './models/subscription';
import type { ServerConnection } from './serverConnection';
import { ensurePlusFeaturesEnabled } from './utils/-webview/plus.utils';
import { getSubscriptionFromCheckIn } from './utils/checkin.utils';
import { getApplicablePromo } from './utils/promo.utils';
import {
assertSubscriptionState,
computeSubscriptionState,
Expand Down 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
17 changes: 9 additions & 8 deletions src/plus/gk/utils/promo.utils.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { PromoKeys } from '../../../constants.promos';
import { promos } from '../../../constants.promos';
import type { SubscriptionState } from '../../../constants.subscription';
import type { Promo, PromoLocation } from '../models/promo';

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

for (const promo of promos) {
if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) {
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;
}
Expand All @@ -20,7 +21,7 @@ export function getApplicablePromo(
}

return undefined;
}
};

function isPromoApplicable(promo: Promo, state: number): boolean {
const now = Date.now();
Expand Down
26 changes: 15 additions & 11 deletions src/webviews/apps/home/components/promo-banner.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { consume } from '@lit/context';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Promo } from '../../../../plus/gk/models/promo';
import { getApplicablePromo } from '../../../../plus/gk/utils/promo.utils';
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 @@ -33,21 +33,25 @@ export class GlPromoBanner extends LitElement {
private _state!: State;

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

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

get promo(): Promo | undefined {
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(): unknown {
if (!this.promo) {
override render() {
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 fd1215a

Please sign in to comment.