Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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