(core) Add basic activation page to grist-ee

Summary:
Adds an activation page to grist-ee that currently shows activation status.

Follow-up diffs will introduce additional functionality, such as the ability to
enter activation keys directly from the activation page.

Test Plan: No grist-ee tests (yet).

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D3582
pull/19/head
George Gevoian 2 years ago
parent 5f17dd0a06
commit ed37401b2c

@ -0,0 +1,5 @@
import {ActivationPage} from 'app/client/ui/ActivationPage';
import {setupPage} from 'app/client/ui/setupPage';
import {dom} from 'grainjs';
setupPage((appModel) => dom.create(ActivationPage, appModel));

@ -0,0 +1,5 @@
import {BillingPage} from 'app/client/ui/BillingPage';
import {setupPage} from 'app/client/ui/setupPage';
import {dom} from 'grainjs';
setupPage((appModel) => dom.create(BillingPage, appModel));

@ -1,213 +0,0 @@
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {IFormData} from 'app/client/ui/BillingForm';
import {BillingAPI, BillingAPIImpl, BillingSubPage,
BillingTask, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI';
import {FullUser} from 'app/common/LoginSessionAPI';
import {bundleChanges, Computed, Disposable, Observable} from 'grainjs';
export interface BillingModel {
readonly error: Observable<string|null>;
// Plans available to the user.
readonly plans: Observable<IBillingPlan[]>;
// Client-friendly version of the IBillingSubscription fetched from the server.
// See ISubscriptionModel for details.
readonly subscription: Observable<ISubscriptionModel|undefined>;
readonly currentSubpage: Computed<BillingSubPage|undefined>;
// The billingTask query param of the url - indicates the current operation, if any.
// See BillingTask in BillingAPI for details.
readonly currentTask: Computed<BillingTask|undefined>;
// The planId of the plan to which the user is in process of signing up.
readonly signupPlanId: Computed<string|undefined>;
// The plan to which the user is in process of signing up.
readonly signupPlan: Computed<IBillingPlan|undefined>;
// Indicates whether the request for billing account information fails with unauthorized.
// Initialized to false until the request is made.
readonly isUnauthorized: Observable<boolean>;
reportBlockingError(this: void, err: Error): void;
// Fetch billing account managers.
fetchManagers(): Promise<FullUser[]>;
// Add billing account manager.
addManager(email: string): Promise<void>;
// Remove billing account manager.
removeManager(email: string): Promise<void>;
// Returns a boolean indicating if the org domain string is available.
isDomainAvailable(domain: string): Promise<boolean>;
// Fetches subscription data associated with the given org, if the pages are associated with an
// org and the user is a plan manager. Otherwise, fetches available plans only.
fetchData(forceReload?: boolean): Promise<void>;
// Triggered when submit is clicked on the payment page. Performs the API billing account
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
submitPaymentPage(formData?: IFormData): Promise<void>;
// Cancels current subscription.
cancelCurrentPlan(): Promise<void>;
// Retrieves customer portal session URL.
getCustomerPortalUrl(): string;
// Renews plan (either by opening customer portal or creating Stripe Checkout session)
renewPlan(): string;
// Downgrades team plan. Currently downgrades only from team to team free plan, and
// only when plan is cancelled.
downgradePlan(planName: string): Promise<void>;
}
export interface ISubscriptionModel extends IBillingSubscription {
// The active plan.
activePlan: IBillingPlan|null;
// The upcoming plan, or null if the current plan is not set to end.
upcomingPlan: IBillingPlan|null;
}
/**
* Creates the model for the BillingPage. See app/client/ui/BillingPage for details.
*/
export class BillingModelImpl extends Disposable implements BillingModel {
public readonly error = Observable.create<string|null>(this, null);
// Plans available to the user.
public readonly plans: Observable<IBillingPlan[]> = Observable.create(this, []);
// Client-friendly version of the IBillingSubscription fetched from the server.
// See ISubscriptionModel for details.
public readonly subscription: Observable<ISubscriptionModel|undefined> = Observable.create(this, undefined);
public readonly currentSubpage: Computed<BillingSubPage|undefined> =
Computed.create(this, urlState().state, (use, s) => s.billing === 'billing' ? undefined : s.billing);
// The billingTask query param of the url - indicates the current operation, if any.
// See BillingTask in BillingAPI for details.
public readonly currentTask: Computed<BillingTask|undefined> =
Computed.create(this, urlState().state, (use, s) => s.params && s.params.billingTask);
// The planId of the plan to which the user is in process of signing up.
public readonly signupPlanId: Computed<string|undefined> =
Computed.create(this, urlState().state, (use, s) => s.params && s.params.billingPlan);
// The plan to which the user is in process of signing up.
public readonly signupPlan: Computed<IBillingPlan|undefined> =
Computed.create(this, this.plans, this.signupPlanId, (use, plans, pid) => plans.find(_p => _p.id === pid));
// Indicates whether the request for billing account information fails with unauthorized.
// Initialized to false until the request is made.
public readonly isUnauthorized: Observable<boolean> = Observable.create(this, false);
public readonly reportBlockingError = this._reportBlockingError.bind(this);
private readonly _billingAPI: BillingAPI = new BillingAPIImpl(getHomeUrl());
constructor(private _appModel: AppModel) {
super();
}
// Fetch billing account managers to initialize the dom.
public async fetchManagers(): Promise<FullUser[]> {
const billingAccount = await this._billingAPI.getBillingAccount();
return billingAccount.managers;
}
public async addManager(email: string): Promise<void> {
await this._billingAPI.updateBillingManagers({
users: {[email]: 'managers'}
});
}
public async removeManager(email: string): Promise<void> {
await this._billingAPI.updateBillingManagers({
users: {[email]: null}
});
}
public isDomainAvailable(domain: string): Promise<boolean> {
return this._billingAPI.isDomainAvailable(domain);
}
public getCustomerPortalUrl() {
return this._billingAPI.customerPortal();
}
public renewPlan() {
return this._billingAPI.renewPlan();
}
public async downgradePlan(planName: string): Promise<void> {
await this._billingAPI.downgradePlan(planName);
}
public async cancelCurrentPlan() {
const data = await this._billingAPI.cancelCurrentPlan();
return data;
}
public async submitPaymentPage(formData: IFormData = {}): Promise<void> {
const task = this.currentTask.get();
// TODO: The server should prevent most of the errors in this function from occurring by
// redirecting improper urls.
try {
if (task === 'signUpLite' || task === 'updateDomain') {
// All that can change here is company name, and domain.
const org = this._appModel.currentOrg;
const name = formData.settings && formData.settings.name;
const domain = formData.settings && formData.settings.domain;
const newDomain = domain !== org?.domain;
const newSettings = org && (name !== org.name || newDomain) && formData.settings;
// If the address or settings have a new value, run the update.
if (newSettings) {
await this._billingAPI.updateSettings(newSettings || undefined);
}
// If the domain has changed, should redirect page.
if (newDomain) {
window.location.assign(urlState().makeUrl({ org: domain, billing: 'billing', params: undefined }));
return;
}
// If there is an org update, re-initialize the org in the client.
if (newSettings) { this._appModel.topAppModel.initialize(); }
} else {
throw new Error('BillingPage _submit error: no task in progress');
}
// Show the billing summary page after submission
await urlState().pushUrl({ billing: 'billing', params: undefined });
} catch (err) {
// TODO: These errors may need to be reported differently since they're not user-friendly
reportError(err);
throw err;
}
}
// If forceReload is set, re-fetches and updates already fetched data.
public async fetchData(forceReload: boolean = false): Promise<void> {
// If these are billing settings pages for an existing org, fetch the subscription data.
await this._fetchSubscription(forceReload);
}
private _reportBlockingError(err: Error) {
// TODO billing pages don't instantiate notifications UI (they probably should).
reportError(err);
const details = (err as any).details;
const message = (details && details.userError) || err.message;
this.error.set(message);
}
private async _fetchSubscription(forceReload: boolean = false): Promise<void> {
if (forceReload || this.subscription.get() === undefined) {
try {
// Unset while fetching for forceReload, so that the user (and tests) can tell that a
// fetch is pending.
this.subscription.set(undefined);
const sub = await this._billingAPI.getSubscription();
bundleChanges(() => {
this.plans.set(sub.plans);
const subModel: ISubscriptionModel = {
activePlan: sub.plans[sub.planIndex],
upcomingPlan: sub.upcomingPlanIndex !== sub.planIndex ? sub.plans[sub.upcomingPlanIndex] : null,
...sub
};
this.subscription.set(subModel);
// Clear the fetch errors on success.
this.isUnauthorized.set(false);
this.error.set(null);
});
} catch (e) {
if (e.status === 401 || e.status === 403) { this.isUnauthorized.set(true); }
throw e;
}
}
}
}

@ -142,7 +142,7 @@ export class UrlStateImpl {
*/
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
newState.account || newState.billing || newState.welcome) ?
newState.account || newState.billing || newState.activation || newState.welcome) ?
(prevState.org ? {org: prevState.org} : {}) :
prevState;
return {...keepState, ...newState};
@ -163,6 +163,8 @@ export class UrlStateImpl {
const accountReload = Boolean(prevState.account) !== Boolean(newState.account);
// Reload when moving to/from a billing page.
const billingReload = Boolean(prevState.billing) !== Boolean(newState.billing);
// Reload when moving to/from an activation page.
const activationReload = Boolean(prevState.activation) !== Boolean(newState.activation);
// Reload when moving to/from a welcome page.
const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome);
// Reload when link keys change, which changes what the user can access
@ -170,8 +172,8 @@ export class UrlStateImpl {
// Reload when moving to/from the Grist sign-up page.
const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login;
return Boolean(orgReload || accountReload || billingReload || gristConfig.errPage
|| docReload || welcomeReload || linkKeysReload || signupReload);
return Boolean(orgReload || accountReload || billingReload || activationReload
|| gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload);
}
/**

@ -2,6 +2,7 @@ import {loadGristDoc} from 'app/client/lib/imports';
import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState';
import {buildUserMenuBillingItem} from 'app/client/ui/BillingButtons';
import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
import {createUserImage} from 'app/client/ui/UserImage';
import * as viewport from 'app/client/ui/viewport';
@ -12,7 +13,6 @@ import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/clie
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {SUPPORT_EMAIL} from 'app/common/UserAPI';
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel';
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
@ -50,8 +50,6 @@ export class AccountWidget extends Disposable {
private _makeAccountMenu(user: FullUser|null): DomElementArg[] {
const currentOrg = this._appModel.currentOrg;
const gristDoc = this._docPageModel ? this._docPageModel.gristDoc.get() : null;
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
(currentOrg.billingAccount.isManager || user?.email === SUPPORT_EMAIL));
// The 'Document Settings' item, when there is an open document.
const documentSettingsItem = (gristDoc ?
@ -99,16 +97,7 @@ export class AccountWidget extends Disposable {
// Don't show on doc pages, or for personal orgs.
null),
shouldHideUiElement("billing") ? null :
// Show link to billing pages.
this._appModel.isTeamSite ?
// For links, disabling with just a class is hard; easier to just not make it a link.
// TODO weasel menus should support disabling menuItemLink.
(isBillingManager ?
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
menuItem(() => null, 'Billing Account', dom.cls('disabled', true))
) :
menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'),
buildUserMenuBillingItem(this._appModel),
mobileModeToggle,

@ -1,8 +1,7 @@
import * as billingPageCss from 'app/client/ui/BillingPageCss';
import { basicButton } from 'app/client/ui2018/buttons';
import { basicButton, textButton } from 'app/client/ui2018/buttons';
import { icon } from 'app/client/ui2018/icons';
import { confirmModal } from 'app/client/ui2018/modals';
import { Disposable, dom, IDomArgs, makeTestId, Observable, observable, styled } from "grainjs";
import { Disposable, dom, IDomArgs, makeTestId, Observable, observable, styled } from 'grainjs';
interface IWidgetOptions {
apiKey: Observable<string>;
@ -64,7 +63,7 @@ export class ApiKey extends Disposable {
this._inputArgs
),
cssTextBtn(
cssBillingIcon('Remove'), 'Remove',
cssTextBtnIcon('Remove'), 'Remove',
dom.on('click', () => this._showRemoveKeyModal()),
testId('delete'),
dom.boolAttr('disabled', (use) => use(this._loading) || this._anonymous)
@ -133,9 +132,12 @@ const cssRow = styled('div', `
display: flex;
`);
const cssTextBtn = styled(billingPageCss.billingTextBtn, `
const cssTextBtn = styled(textButton, `
text-align: left;
width: 90px;
margin-left: 16px;
`);
const cssBillingIcon = billingPageCss.billingIcon;
const cssTextBtnIcon = styled(icon, `
margin: 0 4px 2px 0;
`);

@ -1,8 +1,8 @@
import {urlState} from 'app/client/models/gristUrlState';
import {buildAppMenuBillingItem} from 'app/client/ui/BillingButtons';
import {getTheme} from 'app/client/ui/CustomThemes';
import {cssLeftPane} from 'app/client/ui/PagePanels';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {shouldHideUiElement} from 'app/common/gristUrls';
import * as version from 'app/common/version';
import {BindableValue, Disposable, dom, styled} from "grainjs";
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
@ -36,7 +36,6 @@ export class AppHeader extends Disposable {
const theme = getTheme(this._appModel.topAppModel.productFlavor);
const currentOrg = this._appModel.currentOrg;
const isBillingManager = this._appModel.isBillingManager() || this._appModel.isSupport();
return cssAppHeader(
cssAppHeader.cls('-widelogo', theme.wideLogo || false),
@ -66,15 +65,7 @@ export class AppHeader extends Disposable {
// Don't show on doc pages, or for personal orgs.
null),
// Show link to billing pages.
currentOrg && !currentOrg.owner && !shouldHideUiElement("billing") ?
// For links, disabling with just a class is hard; easier to just not make it a link.
// TODO weasel menus should support disabling menuItemLink.
(isBillingManager ?
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
menuItem(() => null, 'Billing Account', dom.cls('disabled', true), testId('orgmenu-billing'))
) :
null,
buildAppMenuBillingItem(this._appModel, testId('orgmenu-billing')),
maybeAddSiteSwitcherSection(this._appModel),
], { placement: 'bottom-start' }),

@ -1,244 +0,0 @@
import {BillingModel} from 'app/client/models/BillingModel';
import * as css from 'app/client/ui/BillingPageCss';
import {IBillingOrgSettings} from 'app/common/BillingAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import * as roles from 'app/common/roles';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable} from 'grainjs';
const testId = makeTestId('test-bp-');
export interface IFormData {
settings?: IBillingOrgSettings;
}
// Optional autofill vales to pass in to the BillingForm constructor.
interface IAutofill {
settings?: Partial<IBillingOrgSettings>;
}
// An object containing a function to check the validity of its observable value.
// The get function should return the observable value or throw an error if it is invalid.
interface IValidated<T> {
value: Observable<T>;
checkValidity: (value: T) => void|Promise<void>; // Should throw with message on invalid values.
isInvalid: Observable<boolean>;
get: () => T|Promise<T>;
}
export class BillingForm extends Disposable {
private readonly _settings: BillingSettingsForm|null;
constructor(
org: Organization|null,
billingModel: BillingModel,
options: {settings: boolean, domain: boolean},
autofill: IAutofill = {}
) {
super();
// Org settings form.
this._settings = options.settings ? new BillingSettingsForm(billingModel, org, {
showHeader: true,
showDomain: options.domain,
autofill: autofill.settings
}) : null;
}
public buildDom() {
return [
this._settings ? this._settings.buildDom() : null,
];
}
// Note that this will throw if any values are invalid.
public async getFormData(): Promise<IFormData> {
const settings = this._settings ? await this._settings.getSettings() : undefined;
return {
settings,
};
}
// Make a best-effort attempt to focus the element with the error.
public focusOnError() {
// We don't have a good way to do it, we just try to do better than nothing. Also we don't
// have access to the form container, so look at css.inputError element in the full document.
const elem = document.querySelector(`.${css.paymentBlock.className} .${css.inputError.className}:not(:empty)`);
const parent = elem?.closest(`.${css.paymentBlock.className}`);
if (parent) {
const input: HTMLInputElement|null =
parent.querySelector(`.${css.billingInput.className}-invalid`) ||
parent.querySelector('input');
if (input) {
input.focus();
input.select();
}
}
}
}
// Abstract class which includes helper functions for creating a form whose values are verified.
abstract class BillingSubForm extends Disposable {
protected readonly formError: Observable<string> = Observable.create(this, '');
protected shouldAutoFocus = false;
constructor() {
super();
}
// Creates an input whose value is validated on blur. Input text turns red and the validation
// error is shown on negative validation.
protected billingInput(validated: IValidated<string>, ...args: Array<DomArg<any>>) {
return css.billingInput(validated.value, {onInput: true},
css.billingInput.cls('-invalid', validated.isInvalid),
dom.on('blur', () => this._onBlur(validated)),
...args
);
}
protected async _onBlur(validated: IValidated<string>): Promise<void> {
// Do not show empty input errors on blur.
if (validated.value.get().length === 0) { return; }
try {
await validated.get();
this.formError.set('');
} catch (e) {
this.formError.set(e.message);
}
}
protected maybeAutoFocus() {
if (this.shouldAutoFocus) {
this.shouldAutoFocus = false;
return (elem: HTMLElement) => { setTimeout(() => elem.focus(), 0); };
}
}
}
/**
* Creates the billing settings form, including the org name and the org subdomain values.
*/
class BillingSettingsForm extends BillingSubForm {
private readonly _name: IValidated<string> = createValidated(this, checkRequired('Company name'));
// Only verify the domain if it is shown.
private readonly _domain: IValidated<string> = createValidated(this,
this._options.showDomain ? d => this._verifyDomain(d) : () => undefined);
constructor(
private readonly _billingModel: BillingModel,
private readonly _org: Organization|null,
private readonly _options: {
showHeader: boolean;
showDomain: boolean;
autofill?: Partial<IBillingOrgSettings>;
}
) {
super();
const autofill = this._options.autofill;
if (autofill) {
this._name.value.set(autofill.name || '');
this._domain.value.set(autofill.domain || '');
}
}
public buildDom() {
const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access));
const initDomain = this._options.autofill?.domain;
return css.paymentBlock(
this._options.showHeader ? css.paymentLabel('Team name') : null,
css.paymentRow(
css.paymentField(
this.billingInput(this._name,
dom.boolAttr('disabled', () => noEditAccess),
testId('settings-name')
),
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
testId('settings-name-info')
) : null
)
),
this._options.showDomain ? css.paymentRow(
css.paymentField(
css.paymentLabel('Team subdomain'),
this.billingInput(this._domain,
dom.boolAttr('disabled', () => noEditAccess),
testId('settings-domain')
),
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
testId('settings-domain-info')
) : null,
dom.maybe((use) => initDomain && use(this._domain.value) !== initDomain, () =>
css.paymentFieldDanger('Any saved links will need updating if the URL changes')
),
),
css.paymentField({style: 'flex: 0 1 0;'},
css.inputHintLabel('.getgrist.com')
)
) : null,
css.inputError(
dom.text(this.formError),
testId('settings-form-error')
)
);
}
// Throws if any value is invalid.
public async getSettings(): Promise<IBillingOrgSettings|undefined> {
try {
return {
name: await this._name.get(),
domain: await this._domain.get()
};
} catch (e) {
this.formError.set(e.message);
throw e;
}
}
// Throws if the entered domain contains any invalid characters or is already taken.
private async _verifyDomain(domain: string): Promise<void> {
// OK to retain current domain.
if (domain === this._options.autofill?.domain) { return; }
checkSubdomainValidity(domain);
const isAvailable = await this._billingModel.isDomainAvailable(domain);
if (!isAvailable) { throw new Error('Domain is already taken.'); }
}
}
function checkFunc(func: (val: string) => boolean, message: string) {
return (val: string) => {
if (!func(val)) { throw new Error(message); }
};
}
function checkRequired(propertyName: string) {
return checkFunc(Boolean, `${propertyName} is required.`);
}
// Creates a validated object, which includes an observable and a function to check
// if the current observable value is valid.
function createValidated(
owner: IDisposableOwnerT<any>,
checkValidity: (value: string) => void|Promise<void>,
): IValidated<string> {
const value = Observable.create(owner, '');
const isInvalid = Observable.create<boolean>(owner, false);
owner.autoDispose(value.addListener(() => { isInvalid.set(false); }));
return {
value,
isInvalid,
checkValidity,
get: async () => {
const _value = value.get();
try {
await checkValidity(_value);
} catch (e) {
isInvalid.set(true);
throw e;
}
isInvalid.set(false);
return _value;
}
};
}

@ -1,684 +0,0 @@
import {buildHomeBanners} from 'app/client/components/Banners';
import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
import {urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
import * as css from 'app/client/ui/BillingPageCss';
import {BillingPlanManagers} from 'app/client/ui/BillingPlanManagers';
import {createForbiddenPage} from 'app/client/ui/errorPages';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {showTeamUpgradeConfirmation} from 'app/client/ui/ProductUpgrades';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal} from 'app/client/ui2018/modals';
import {BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
import {displayPlanName, TEAM_FREE_PLAN, TEAM_PLAN} from 'app/common/Features';
import {capitalize} from 'app/common/gutil';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, DomArg, IAttrObj, makeTestId, Observable} from 'grainjs';
const testId = makeTestId('test-bp-');
const billingTasksNames = {
signUp: 'Sign Up', // task for payment page
signUpLite: 'Complete Sign Up', // task for payment page
updateDomain: 'Update Name', // task for summary page
cancelPlan: 'Cancel plan', // this is not a task, but a sub page
upgraded: 'Account',
};
/**
* Creates the billing page where users can manage their subscription and payment card.
*/
export class BillingPage extends Disposable {
private _model: BillingModel = new BillingModelImpl(this._appModel);
private _form: BillingForm | undefined = undefined;
private _formData: IFormData = {};
private _showConfirmPage: Observable<boolean> = Observable.create(this, false);
private _isSubmitting: Observable<boolean> = Observable.create(this, false);
constructor(private _appModel: AppModel) {
super();
this._appModel.refreshOrgUsage().catch(reportError);
}
// Exposed for tests.
public testBuildPaymentPage() {
return this._buildPaymentPage();
}
public buildDom() {
return dom.domComputed(this._model.isUnauthorized, (isUnauthorized) => {
if (isUnauthorized) {
return createForbiddenPage(this._appModel,
'Only billing plan managers may view billing account information. Plan managers may ' +
'be added in the billing summary by existing plan managers.');
} else {
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._createTopBarBilling(),
contentTop: buildHomeBanners(this._appModel),
contentMain: this._buildCurrentPageDom()
});
}
});
}
/**
* Builds the contentMain dom for the current billing page.
*/
private _buildCurrentPageDom() {
const page = css.billingWrapper(
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (!subpage) {
return this._buildSummaryPage();
} else if (subpage === 'payment') {
return this._buildPaymentPage();
}
})
);
if (this._model.currentTask.get() === 'upgraded') {
urlState().pushUrl({params: {}}, { replace: true }).catch(() => {});
showTeamUpgradeConfirmation(this);
}
return page;
}
private _buildSummaryPage() {
const org = this._appModel.currentOrg;
// Fetch plan and card data.
this._model.fetchData(true).catch(reportError);
return css.billingPage(
dom.domComputed(this._model.currentTask, (task) => {
const pageText = task ? billingTasksNames[task] : 'Account';
return [
css.cardBlock(
css.billingHeader(pageText),
task !== 'updateDomain' ? [
dom.domComputed(this._model.subscription, () => [
this._buildDomainSummary(org ?? {}),
]),
// If this is not a personal org, create the plan manager list dom.
org && !org.owner ? dom.frag(
css.billingHeader('Plan Managers', { style: 'margin: 32px 0 16px 0;' }),
css.billingHintText(
'You may add additional billing contacts (for example, your accounting department). ' +
'All billing-related emails will be sent to this list of contacts.'
),
dom.create(BillingPlanManagers, this._model, org, this._appModel.currentValidUser)
) : null
] : dom.domComputed(this._showConfirmPage, (showConfirm) => {
if (showConfirm) {
return [
this._buildConfirm(this._formData),
this._buildButtons(pageText)
];
} else {
return this._buildForms(org, task);
}
})
),
css.summaryBlock(
css.billingHeader('Billing Summary'),
this._buildSubscriptionSummary(),
)
];
})
);
}
// PRIVATE - exposed for tests
private _buildPaymentPage() {
const org = this._appModel.currentOrg;
// Fetch plan and card data if not already present.
this._model.fetchData().catch(this._model.reportBlockingError);
return css.billingPage(
dom.maybe(this._model.currentTask, task => {
const pageText = billingTasksNames[task];
return [
css.cardBlock(
css.billingHeader(pageText),
dom.domComputed((use) => {
const err = use(this._model.error);
if (err) {
return css.errorBox(err, dom('br'), dom('br'), reportLink(this._appModel, "Report problem"));
}
const sub = use(this._model.subscription);
if (!sub) {
return css.spinnerBox(loadingSpinner());
}
if (task === 'cancelPlan') {
// If the selected plan is free, the user is cancelling their subscription.
return [
css.paymentBlock(
'On the subscription end date, your team site will remain available in ' +
'read-only mode for one month.',
),
css.paymentBlock(
'After the one month grace period, your team site will be removed along ' +
'with all documents inside.'
),
css.paymentBlock('Are you sure you would like to cancel the subscription?'),
this._buildButtons('Cancel Subscription')
];
} else { // tasks - signUpLite
return dom.domComputed(this._showConfirmPage, (showConfirm) => {
if (showConfirm) {
return [
this._buildConfirm(this._formData),
this._buildButtons(pageText)
];
} else {
return this._buildForms(org, task);
}
});
}
})
),
css.summaryBlock(
css.billingHeader('Summary'),
css.summaryFeatures(
this._buildPaymentSummary(task),
testId('summary')
)
)
];
})
);
}
private _buildSubscriptionSummary() {
return dom.domComputed(this._model.subscription, sub => {
if (!sub) {
return css.spinnerBox(loadingSpinner());
} else {
const moneyPlan = sub.upcomingPlan || sub.activePlan;
const changingPlan = sub.upcomingPlan && sub.upcomingPlan.amount > 0;
const cancellingPlan = sub.upcomingPlan && sub.upcomingPlan.amount === 0;
const validPlan = sub.isValidPlan;
const discountName = sub.discount && sub.discount.name;
const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
const tier = discountName && discountName.includes(' Tier ');
const activePlanName = sub.activePlan?.nickname ??
displayPlanName[this._appModel.planName || ''] ?? this._appModel.planName;
const planName = tier ? discountName : activePlanName;
const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
const isPaidPlan = sub.billable;
// If subscription is canceled, we need to create a new one using Stripe Checkout.
const canRenew = (sub.status === 'canceled' && isPaidPlan);
// We can upgrade only free team plan at this moment.
const canUpgrade = !canRenew && !isPaidPlan;
// And we can manage team plan that is not canceled.
const canManage = !canRenew && isPaidPlan;
const isCanceled = sub.status === 'canceled';
const wasTeam = this._appModel.planName === TEAM_PLAN && isCanceled && !validPlan;
return [
css.summaryFeatures(
validPlan && planName ? [
makeSummaryFeature(['You are subscribed to the ', planName, ' plan'])
] : [
isCanceled ?
makeSummaryFeature(['You were subscribed to the ', planName, ' plan'], { isBad: true }) :
makeSummaryFeature(['This team site is not in good standing'], { isBad: true }),
],
// If the plan is changing, include the date the current plan ends
// and the plan that will be in effect afterwards.
changingPlan && isPaidPlan ? [
makeSummaryFeature(['Your current plan ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, you will be subscribed to the ',
sub.upcomingPlan?.nickname ?? '-', ' plan'])
] : null,
cancellingPlan && isPaidPlan ? [
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, your team site will become read-only'])
] : null,
moneyPlan?.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
` member${sub.userCount !== 1 ? 's' : ''}`]),
tier ? this._makeAppSumoFeature(discountName) : null,
// Currently the subtotal is misleading and scary when tiers are in effect.
// In this case, for now, just report what will be invoiced.
!tier ? makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
getPriceString(moneyPlan.amount * sub.userCount)]) : null,
(discountName && !tier) ?
makeSummaryFeature([
`You receive the `,
discountName,
...(discountEnd !== null ? [' (until ', dateFmtFull(discountEnd), ')'] : []),
]) :
null,
// When on a free trial, Stripe reports trialEnd time, but it seems to always
// match periodEnd for a trialing subscription, so we just use that.
sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null,
makeSummaryFeature([`Your next invoice is `, getPriceString(sub.nextTotal),
' on ', dateFmt(sub.periodEnd)]),
] : null,
appSumoInvoiced ? makeAppSumoLink(appSumoInvoiced) : null,
getSubscriptionProblem(sub),
testId('summary')
),
!canManage ? null :
makeActionLink('Manage billing', 'Settings', this._model.getCustomerPortalUrl(), testId('portal-link')),
!wasTeam ? null : makeActionButton('Downgrade plan', 'Settings',
() => this._confirmDowngradeToTeamFree(), testId('downgrade-free-link')),
!canRenew ? null :
makeActionLink('Renew subscription', 'Settings', this._model.renewPlan(), testId('renew-link')),
!canUpgrade ? null :
makeActionButton('Upgrade subscription', 'Settings',
() => this._appModel.showUpgradeModal(), testId('upgrade-link')),
!(validPlan && planName && isPaidPlan && !cancellingPlan) ? null :
makeActionLink(
'Cancel subscription',
'Settings',
urlState().setLinkUrl({
billing: 'payment',
params: {
billingTask: 'cancelPlan'
}
}),
testId('cancel-subscription')
),
(sub.lastInvoiceUrl && sub.activeSubscription ?
makeActionLink('View last invoice', 'Page', sub.lastInvoiceUrl, testId('invoice-link'))
: null
),
];
}
});
}
private _confirmDowngradeToTeamFree() {
confirmModal('Downgrade to Free Team Plan',
'Downgrade',
() => this._downgradeToTeamFree(),
dom('div', {style: `color: ${colors.dark}`}, testId('downgrade-confirm-modal'),
dom('div', 'Documents on free team plan are subject to the following limits. '
+'Any documents in excess of these limits will be put in read-only mode.'),
dom('ul',
dom('li', { style: 'margin-bottom: 0.6em'}, dom('strong', '5,000'), ' rows per document'),
dom('li', { style: 'margin-bottom: 0.6em'}, dom('strong', '10MB'), ' max document size'),
dom('li', 'API limit: ', dom('strong', '5k'), ' calls/day'),
)
),
);
}
private async _downgradeToTeamFree() {
// Perform the downgrade operation.
await this._model.downgradePlan(TEAM_FREE_PLAN);
// Refresh app model
this._appModel.topAppModel.initialize();
}
private _makeAppSumoFeature(name: string) {
// TODO: move AppSumo plan knowledge elsewhere.
let users = 0;
switch (name) {
case 'AppSumo Tier 1':
users = 1;
break;
case 'AppSumo Tier 2':
users = 3;
break;
case 'AppSumo Tier 3':
users = 8;
break;
}
if (users > 0) {
return makeSummaryFeature([`Your AppSumo plan covers `,
`${users}`,
` member${users > 1 ? 's' : ''}`]);
}
return null;
}
private _buildForms(org: Organization | null, task: BillingTask) {
const isTeamSite = org && org.billingAccount && !org.billingAccount.individual;
const currentSettings = this._formData.settings ?? {
name: org!.name,
domain: org?.domain?.startsWith('o-') ? undefined : org?.domain || undefined,
};
const pageText = billingTasksNames[task];
// If there is an immediate charge required, require re-entering the card info.
// Show all forms on sign up.
this._form = new BillingForm(
org,
this._model,
{
settings: ['signUpLite', 'updateDomain'].includes(task),
domain: ['signUpLite', 'updateDomain'].includes(task)
},
{
settings: currentSettings,
}
);
return dom('div',
dom.onDispose(() => {
if (this._form) {
this._form.dispose();
this._form = undefined;
}
}),
isTeamSite ? this._buildDomainSummary(currentSettings ?? {}) : null,
this._form.buildDom(),
this._buildButtons(pageText)
);
}
private _buildConfirm(formData: IFormData) {
const settings = formData.settings || null;
return [
this._buildDomainConfirmation(settings ?? {}, false),
];
}
private _createTopBarBilling() {
const org = this._appModel.currentOrg;
return dom.frag(
cssBreadcrumbs({ style: 'margin-left: 16px;' },
cssBreadcrumbsLink(
urlState().setLinkUrl({}),
'Home',
testId('home')
),
separator(' / '),
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (subpage) {
return [
// Prevent navigating to the summary page if these pages are not associated with an org.
org && !org.owner ? cssBreadcrumbsLink(
urlState().setLinkUrl({ billing: 'billing' }),
'Billing',
testId('billing')
) : dom('span', 'Billing'),
separator(' / '),
dom('span', capitalize(subpage))
];
} else {
return dom('span', 'Billing');
}
})
),
createTopBarHome(this._appModel),
);
}
private _buildDomainConfirmation(org: { name?: string | null, domain?: string | null }, showEdit: boolean = true) {
return css.summaryItem(
css.summaryHeader(
css.billingBoldText('Team Info'),
),
org?.name ? [
css.summaryRow(
{ style: 'margin-bottom: 0.6em' },
css.billingText(`Your team name: `,
dom('span', { style: 'font-weight: bold' }, org?.name, testId('org-name')),
),
)
] : null,
org?.domain ? [
css.summaryRow(
css.billingText(`Your team site URL: `,
dom('span', { style: 'font-weight: bold' }, org?.domain),
`.getgrist.com`,
testId('org-domain')
),
showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'billing',
params: { billingTask: 'updateDomain' }
}),
testId('update-domain')
) : null
)
] : null
);
}
private _buildDomainSummary(org: { name?: string | null, domain?: string | null }, showEdit: boolean = true) {
const task = this._model.currentTask.get();
if (task === 'signUpLite' || task === 'updateDomain') { return null; }
return this._buildDomainConfirmation(org, showEdit);
}
// Summary panel for payment subpage.
private _buildPaymentSummary(task: BillingTask) {
if (task === 'signUpLite') {
return this._buildSubscriptionSummary();
} else if (task === 'updateDomain') {
return makeSummaryFeature('You are updating the site name and domain');
} else if (task === 'cancelPlan') {
return dom.domComputed(this._model.subscription, sub => {
return [
makeSummaryFeature(['You are cancelling the subscription']),
sub ? makeSummaryFeature(['Your subscription will end on ', dateFmt(sub.periodEnd)]) : null
];
});
} else {
return null;
}
}
private _buildButtons(submitText: string) {
const task = this._model.currentTask.get();
this._isSubmitting.set(false); // Reset status on build.
return css.paymentBtnRow(
task !== 'signUpLite' ? bigBasicButton('Back',
dom.on('click', () => window.history.back()),
dom.show((use) => !use(this._showConfirmPage)),
dom.boolAttr('disabled', this._isSubmitting),
testId('back')
) : null,
task !== 'cancelPlan' ? bigBasicButtonLink('Edit',
dom.show(this._showConfirmPage),
dom.on('click', () => this._showConfirmPage.set(false)),
dom.boolAttr('disabled', this._isSubmitting),
testId('edit')
) : null,
bigPrimaryButton({ style: 'margin-left: 10px;' },
dom.text(submitText),
dom.boolAttr('disabled', this._isSubmitting),
dom.on('click', () => this._doSubmit(task)),
testId('submit')
)
);
}
// Submit the active form.
private async _doSubmit(task?: BillingTask): Promise<void> {
if (this._isSubmitting.get()) { return; }
this._isSubmitting.set(true);
try {
if (task === 'cancelPlan') {
await this._model.cancelCurrentPlan();
this._showConfirmPage.set(false);
this._formData = {};
await urlState().pushUrl({ billing: 'billing', params: undefined });
return;
}
// If the form is built, fetch the form data.
if (this._form) {
this._formData = await this._form.getFormData();
}
// In general, submit data to the server.
if (task === 'updateDomain' || this._showConfirmPage.get()) {
await this._model.submitPaymentPage(this._formData);
// On submit, reset confirm page and form data.
this._showConfirmPage.set(false);
this._formData = {};
} else {
this._showConfirmPage.set(true);
this._isSubmitting.set(false);
}
} catch (err) {
// Note that submitPaymentPage are responsible for reporting errors.
// On failure the submit button isSubmitting state should be returned to false.
if (!this.isDisposed()) {
this._isSubmitting.set(false);
this._showConfirmPage.set(false);
// Focus the first element with an error.
this._form?.focusOnError();
}
}
}
}
const statusText: { [key: string]: string } = {
incomplete: 'incomplete',
incomplete_expired: 'incomplete',
past_due: 'past due',
canceled: 'canceled',
unpaid: 'unpaid',
};
function getSubscriptionProblem(sub: ISubscriptionModel) {
const text = sub.status && statusText[sub.status];
if (!text) { return null; }
const result = [['Your subscription is ', text]];
if (sub.lastChargeError) {
const when = sub.lastChargeTime ? `on ${timeFmt(sub.lastChargeTime)} ` : '';
result.push([`Last charge attempt ${when} failed: ${sub.lastChargeError}`]);
}
return result.map(msg => makeSummaryFeature(msg, { isBad: true }));
}
interface PriceOptions {
taxRate?: number;
coupon?: IBillingCoupon;
refund?: number;
}
const defaultPriceOptions: PriceOptions = {
taxRate: 0,
coupon: undefined,
refund: 0,
};
function getPriceString(priceCents: number, options = defaultPriceOptions): string {
const { taxRate = 0, coupon, refund } = options;
if (coupon) {
if (coupon.amount_off) {
priceCents -= coupon.amount_off;
} else if (coupon.percent_off) {
priceCents -= (priceCents * (coupon.percent_off / 100));
}
}
if (refund) {
priceCents -= refund;
}
// Make sure we never display negative prices.
priceCents = Math.max(0, priceCents);
// TODO: Add functionality for other currencies.
return ((priceCents / 100) * (taxRate + 1)).toLocaleString('en-US', {
style: "currency",
currency: "USD",
minimumFractionDigits: 2
});
}
// Include a precise link back to AppSumo for changing plans.
function makeAppSumoLink(invoiceId: string) {
return dom('div',
css.billingTextBtn({ style: 'margin: 10px 0;' },
cssBreadcrumbsLink(
css.billingIcon('Plus'), 'Change your AppSumo plan',
{
href: `https://appsumo.com/account/redemption/${invoiceId}/#change-plan`,
target: '_blank'
},
testId('appsumo-link')
)
));
}
/**
* Make summary feature to include in:
* - Plan cards for describing features of the plan.
* - Summary lists describing what is being paid for and how much will be charged.
* - Summary lists describing the current subscription.
*
* Accepts text as an array where strings at every odd numbered index are bolded for emphasis.
* If isMissingFeature is set, no text is bolded and the optional attribute object is not applied.
* If isBad is set, a cross is used instead of a tick
*/
function makeSummaryFeature(
text: string | string[],
options: { isMissingFeature?: boolean, isBad?: boolean, attr?: IAttrObj } = {}
) {
const textArray = Array.isArray(text) ? text : [text];
if (options.isMissingFeature) {
return css.summaryMissingFeature(
textArray,
testId('summary-line')
);
} else {
return css.summaryFeature(options.attr,
options.isBad ? css.billingBadIcon('CrossBig') : css.billingIcon('Tick'),
textArray.map((str, i) => (i % 2) ? css.focusText(str) : css.summaryText(str)),
testId('summary-line')
);
}
}
function makeActionLink(text: string, icon: IconName, url: DomArg<HTMLElement>, ...args: DomArg<HTMLElement>[]) {
return dom('div',
css.billingTextBtn(
{ style: 'margin: 10px 0;' },
cssBreadcrumbsLink(
css.billingIcon(icon), text,
typeof url === 'string' ? { href: url } : url,
...args,
)
)
);
}
function makeActionButton(text: string, icon: IconName, handler: () => any, ...args: DomArg<HTMLElement>[]) {
return css.billingTextBtn(
{ style: 'margin: 10px 0;' },
css.billingIcon(icon), text,
dom.on('click', handler),
...args
);
}
function reportLink(appModel: AppModel, text: string): HTMLElement {
return dom('a', { href: '#' }, text,
dom.on('click', (ev) => { ev.preventDefault(); beaconOpenMessage({ appModel }); })
);
}
function dateFmt(timestamp: number | null): string {
if (!timestamp) { return "unknown"; }
const date = new Date(timestamp);
if (date.getFullYear() !== new Date().getFullYear()) {
return dateFmtFull(timestamp);
}
return new Date(timestamp).toLocaleDateString('default', { month: 'long', day: 'numeric' });
}
function dateFmtFull(timestamp: number | null): string {
if (!timestamp) { return "unknown"; }
return new Date(timestamp).toLocaleDateString('default', { month: 'short', day: 'numeric', year: 'numeric' });
}
function timeFmt(timestamp: number): string {
return new Date(timestamp).toLocaleString('default',
{ month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
}

@ -1,291 +0,0 @@
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, mediaSmall, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {input, styled} from 'grainjs';
// Note that the special settings remove the zip code number spinner
export const inputStyle = `
font-size: ${vars.mediumFontSize};
height: 42px;
line-height: 16px;
width: 100%;
padding: 13px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
&-invalid {
color: red;
}
&[type=number] {
-moz-appearance: textfield;
}
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
`;
export const billingInput = styled(input, inputStyle);
export const stripeInput = styled('div', inputStyle);
export const billingWrapper = styled('div', `
overflow-y: auto;
`);
export const plansPage = styled('div', `
margin: 60px 10%;
`);
export const plansContainer = styled('div', `
display: flex;
justify-content: space-around;
flex-wrap: wrap;
margin: 45px -1%;
`);
export const planBox = styled('div', `
flex: 1 1 0;
max-width: 295px;
border: 1px solid ${colors.mediumGrey};
border-radius: 1px;
padding: 40px;
margin: 0 1% 30px 1%;
&:last-child {
border: 1px solid ${colors.lightGreen};
}
`);
export const planInterval = styled('div', `
display: inline-block;
color: ${colors.slate};
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.mediumFontSize};
margin-left: 8px;
`);
export const summaryFeature = styled('div', `
color: ${colors.dark};
margin: 24px 0 24px 20px;
text-indent: -20px;
`);
export const summaryMissingFeature = styled('div', `
color: ${colors.slate};
margin: 12px 0 12px 20px;
`);
export const summarySpacer = styled('div', `
height: 28px;
`);
export const upgradeBtn = styled(bigPrimaryButtonLink, `
width: 100%;
margin: 15px 0 0 0;
text-align: center;
`);
export const currentBtn = styled(bigBasicButton, `
width: 100%;
margin: 20px 0 0 0;
cursor: default;
`);
export const billingPage = styled('div', `
display: flex;
max-width: 1000px;
margin: auto;
@media ${mediaSmall} {
& {
display: block;
}
}
`);
export const billingHeader = styled('div', `
height: 32px;
line-height: 32px;
margin: 0 0 16px 0;
color: ${colors.dark};
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
.${planBox.className}:last-child > & {
color: ${colors.lightGreen};
}
`);
export const billingSubHeader = styled('div', `
margin: 16px 0 24px 0;
color: ${colors.dark};
font-size: ${vars.mediumFontSize};
font-weight: bold;
`);
export const billingText = styled('div', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
`);
export const billingBoldText = styled(billingText, `
font-weight: bold;
`);
export const billingHintText = styled('div', `
font-size: ${vars.mediumFontSize};
color: ${colors.slate};
`);
// TODO: Adds a style for when the button is disabled.
export const billingTextBtn = styled('button', `
font-size: ${vars.mediumFontSize};
color: ${colors.lightGreen};
cursor: pointer;
margin-left: 24px;
background-color: transparent;
border: none;
padding: 0;
text-align: left;
&:hover {
color: ${colors.darkGreen};
}
`);
export const billingIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin: 0 4px 2px 0;
.${billingTextBtn.className}:hover > & {
background-color: ${colors.darkGreen};
}
`);
export const billingBadIcon = styled(icon, `
background-color: ${colors.error};
margin: 0 4px 2px 0;
`);
export const summaryItem = styled('div', `
padding: 12px 0 26px 0;
`);
export const summaryFeatures = styled('div', `
margin: 40px 0;
`);
export const summaryText = styled('span', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
`);
export const focusText = styled('span', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
font-weight: bold;
`);
export const cardBlock = styled('div', `
flex: 1 1 60%;
margin: 60px;
@media ${mediaSmall} {
& {
margin: 24px;
}
}
`);
export const summaryRow = styled('div', `
display: flex;
`);
export const summaryHeader = styled(summaryRow, `
margin-bottom: 16px;
`);
export const summaryBlock = styled('div', `
flex: 1 1 40%;
margin: 60px;
@media ${mediaSmall} {
& {
margin: 24px;
}
}
`);
export const flexSpace = styled('div', `
flex: 1 1 0px;
`);
export const paymentSubHeader = styled('div', `
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.xxlargeFontSize};
color: ${colors.dark};
line-height: 60px;
`);
export const paymentField = styled('div', `
display: block;
flex: 1 1 0;
margin: 4px 0;
min-width: 120px;
`);
export const paymentFieldInfo = styled('div', `
color: #929299;
margin: 10px 0;
`);
export const paymentFieldDanger = styled('div', `
color: #ffa500;
margin: 10px 0;
`);
export const paymentSpacer = styled('div', `
width: 38px;
`);
export const paymentLabel = styled('label', `
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
line-height: 38px;
`);
export const inputHintLabel = styled('div', `
margin: 50px 5px 10px 5px;
`);
export const paymentBlock = styled('div', `
margin: 0 0 20px 0;
`);
export const paymentRow = styled('div', `
display: flex;
`);
export const paymentBtnRow = styled('div', `
display: flex;
margin-top: 30px;
justify-content: flex-end;
`);
export const inputError = styled('div', `
height: 16px;
color: red;
`);
export const spinnerBox = styled('div', `
margin: 60px;
text-align: center;
`);
export const errorBox = styled('div', `
margin: 60px 0;
`);

@ -1,179 +0,0 @@
import {getHomeUrl, reportError} from 'app/client/models/AppModel';
import {BillingModel} from 'app/client/models/BillingModel';
import {createUserImage} from 'app/client/ui/UserImage';
import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem,
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssRemoveIcon} from 'app/client/ui/UserItem';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {normalizeEmail} from 'app/common/emails';
import {FullUser} from 'app/common/LoginSessionAPI';
import {Organization, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {Computed, Disposable, dom, obsArray, ObsArray, Observable, styled} from 'grainjs';
import pick = require('lodash/pick');
export class BillingPlanManagers extends Disposable {
private readonly _userAPI: UserAPI = new UserAPIImpl(getHomeUrl());
private readonly _email: Observable<string> = Observable.create(this, "");
private readonly _managers = this.autoDispose(obsArray<FullUser>([]));
private readonly _orgMembers: ObsArray<FullUser> = this.autoDispose(obsArray<FullUser>([]));
private readonly _isValid: Observable<boolean> = Observable.create(this, false);
private readonly _loading: Observable<boolean> = Observable.create(this, true);
private _emailElem: HTMLInputElement;
constructor(
private readonly _model: BillingModel,
private readonly _currentOrg: Organization,
private readonly _currentValidUser: FullUser|null
) {
super();
this._initialize().catch(reportError);
}
public buildDom() {
const enableAdd: Computed<boolean> = Computed.create(null, (use) =>
Boolean(use(this._email) && use(this._isValid) && !use(this._loading)));
return dom('div',
dom.autoDispose(enableAdd),
cssMemberList(
dom.forEach(this._managers, manager => this._buildManagerRow(manager)),
),
cssEmailInputRow(
cssEmailInputContainer({style: `flex: 1 1 0; margin: 0 7px 0 0;`},
cssMailIcon('Mail'),
this._emailElem = cssEmailInput(this._email, {onInput: true, isValid: this._isValid},
{type: "email", placeholder: "Enter email address"},
dom.on('keyup', (e: KeyboardEvent) => {
switch (e.keyCode) {
case 13: return this._commit();
default: return this._update();
}
}),
dom.boolAttr('disabled', this._loading)
),
cssEmailInputContainer.cls('-green', enableAdd),
cssEmailInputContainer.cls('-disabled', this._loading),
testId('bpm-manager-new')
),
bigPrimaryButton('Add Billing Contact',
dom.on('click', () => this._commit()),
dom.boolAttr('disabled', (use) => !use(enableAdd)),
testId('bpm-manager-add')
)
)
);
}
private _buildManagerRow(manager: FullUser) {
const isCurrentUser = this._currentValidUser && manager.id === this._currentValidUser.id;
return cssMemberListItem({style: 'width: auto;'},
cssMemberImage(
createUserImage(manager, 'large')
),
cssMemberText(
cssMemberPrimary(manager.name || dom('span', manager.email, testId('bpm-email'))),
manager.name ? cssMemberSecondary(manager.email, testId('bpm-email')) : null
),
cssMemberBtn(
cssRemoveIcon('Remove', testId('bpm-manager-delete')),
cssMemberBtn.cls('-disabled', (use) => Boolean(use(this._loading) || isCurrentUser)),
// Click handler.
dom.on('click', () => this._loading.get() || isCurrentUser || this._remove(manager))
),
testId('bpm-manager')
);
}
private async _initialize(): Promise<void> {
if (this._currentValidUser) {
const managers = await this._model.fetchManagers();
const {users} = await this._userAPI.getOrgAccess(this._currentOrg.id);
// This next line is here primarily for tests, where pages may be opened and closed
// rapidly and we only want to log "real" errors.
if (this.isDisposed()) { return; }
const fullUsers = users.filter(u => u.access).map(u => pick(u, ['id', 'name', 'email', 'picture']));
this._managers.set(managers);
this._orgMembers.set(fullUsers);
this._loading.set(false);
}
}
// Add the currently entered email if valid, or trigger a validation message if not.
private async _commit() {
await this._update();
if (this._email.get() && this._isValid.get()) {
try {
await this._add(this._email.get());
this._email.set("");
this._emailElem.focus();
} catch (e) {
this._emailElem.setCustomValidity(e.message);
}
}
(this._emailElem as any).reportValidity();
}
private async _update() {
this._emailElem.setCustomValidity("");
this._isValid.set(this._emailElem.checkValidity());
}
// Add the user with the given email as a plan manager.
private async _add(email: string): Promise<void> {
email = normalizeEmail(email);
const member = this._managers.get().find((m) => m.email === email);
const possible = this._orgMembers.get().find((m) => m.email === email);
// These errors should be reported by the email validity checker in _commit().
if (member) { throw new Error("This user is already in the list"); }
// TODO: Allow adding non-members of the org as billing plan managers with confirmation.
if (!possible) { throw new Error("Only members of the org can be billing plan managers"); }
this._loading.set(true);
await this._doAddManager(possible);
this._loading.set(false);
}
// Remove the user from the list of plan managers.
private async _remove(manager: FullUser): Promise<void> {
this._loading.set(true);
try {
await this._model.removeManager(manager.email);
const index = this._managers.get().findIndex((m) => m.id === manager.id);
this._managers.splice(index, 1);
} catch (e) {
// TODO: Report error in a friendly way.
reportError(e);
}
this._loading.set(false);
}
// TODO: Use to confirm adding non-org members as plan managers.
// private _showConfirmAdd(orgName: string, user: FullUser) {
// const nameSpaced = user.name ? `${user.name} ` : '';
// return confirmModal('Add Plan Manager', 'Add', () => this._doAddManager(user),
// `User ${nameSpaced}with email ${user.email} is not a member of organization ${orgName}. ` +
// `Add user to ${orgName}?`)
// }
private async _doAddManager(user: FullUser) {
try {
await this._model.addManager(user.email);
this._managers.push(user);
} catch (e) {
// TODO: Report error in a friendly way.
reportError(e);
}
}
}
const cssMemberList = styled('div', `
flex: 1 1 0;
margin: 20px 0;
width: 100%;
overflow-y: auto;
`);
const cssEmailInputRow = styled('div', `
display: flex;
margin: 28px 0;
`);

@ -1,11 +1,11 @@
import { Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs";
import { Disposable, dom, domComputed, DomContents, MultiHolder, Observable, styled } from "grainjs";
import { handleSubmit, submitForm } from "app/client/lib/formUtils";
import { AppModel, reportError } from "app/client/models/AppModel";
import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState";
import { AccountWidget } from "app/client/ui/AccountWidget";
import { AppHeader } from 'app/client/ui/AppHeader';
import * as BillingPageCss from "app/client/ui/BillingPageCss";
import { textInput } from 'app/client/ui/inputs';
import { pagePanels } from "app/client/ui/PagePanels";
import { createUserImage } from 'app/client/ui/UserImage';
import { cssMemberImage, cssMemberListItem, cssMemberPrimary,
@ -325,7 +325,13 @@ const cssButtonGroup = styled('div', `
}
`);
const cssInput = styled(input, BillingPageCss.inputStyle);
const cssInput = styled(textInput, `
display: inline;
height: 42px;
line-height: 16px;
padding: 13px;
border-radius: 3px;
`);
const cssOrgButton = styled(bigPrimaryButtonLink, `
margin: 0 0 8px;

@ -0,0 +1,42 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
export const cssInput = styled('input', `
font-size: ${vars.mediumFontSize};
height: 48px;
line-height: 20px;
width: 100%;
padding: 14px;
border: 1px solid #D9D9D9;
border-radius: 4px;
outline: none;
display: block;
&[type=number] {
-moz-appearance: textfield;
}
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&-invalid {
border: 1px solid ${colors.error};
}
&-valid {
border: 1px solid ${colors.lightGreen};
}
`);
/**
* Builds a text input that updates `obs` as you type.
*/
export function textInput(obs: Observable<string>, ...args: DomElementArg[]): HTMLInputElement {
return cssInput(
dom.prop('value', obs),
dom.on('input', (_e, elem) => obs.set(elem.value)),
...args,
);
}

@ -34,6 +34,9 @@ export type WelcomePage = typeof WelcomePage.type;
export const AccountPage = StringUnion('account');
export type AccountPage = typeof AccountPage.type;
export const ActivationPage = StringUnion('activation');
export type ActivationPage = typeof ActivationPage.type;
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type;
@ -61,6 +64,7 @@ export const commonUrls = {
plans: "https://www.getgrist.com/pricing",
createTeamSite: "https://www.getgrist.com/create-team-site",
sproutsProgram: "https://www.getgrist.com/sprouts-program",
contact: "https://www.getgrist.com/contact",
efcrConnect: 'https://efc-r.com/connect',
efcrHelp: 'https://www.nioxus.info/eFCR-Help',
@ -82,6 +86,7 @@ export interface IGristUrlState {
docPage?: IDocPage;
account?: AccountPage;
billing?: BillingPage;
activation?: ActivationPage;
login?: LoginPage;
welcome?: WelcomePage;
welcomeTour?: boolean;
@ -230,6 +235,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
parts.push(state.billing === 'billing' ? 'billing' : `billing/${state.billing}`);
}
if (state.activation) { parts.push(state.activation); }
if (state.login) { parts.push(state.login); }
if (state.welcome) {
@ -316,6 +323,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
if (map.has('m')) { state.mode = OpenDocMode.parse(map.get('m')); }
if (map.has('account')) { state.account = AccountPage.parse(map.get('account')) || 'account'; }
if (map.has('billing')) { state.billing = BillingSubPage.parse(map.get('billing')) || 'billing'; }
if (map.has('activation')) {
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
}
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
@ -538,7 +548,7 @@ export interface GristLoadConfig {
// Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups.
tagManagerId?: string;
activation?: ActivationState;
activation?: Activation;
// Parts of the UI to hide
hideUiElements?: IHideableUiElement[];
@ -563,16 +573,22 @@ export function getPageTitleSuffix(config?: GristLoadConfig) {
* summarizes the current state. Not applicable to grist-core.
*/
export interface ActivationState {
trial?: { // Present when installation has not yet been activated.
days: number; // Max number of days allowed prior to activation.
daysLeft: number; // Number of days left until Grist will get cranky.
trial?: { // Present when installation has not yet been activated.
days: number; // Max number of days allowed prior to activation.
expirationDate: string; // ISO8601 date that Grist will get cranky.
daysLeft: number; // Number of days left until Grist will get cranky.
}
needKey?: boolean; // Set when Grist is cranky and demanding activation.
key?: { // Set when Grist is activated.
daysLeft?: number; // Number of days until Grist will need reactivation.
needKey?: boolean; // Set when Grist is cranky and demanding activation.
key?: { // Set when Grist is activated.
expirationDate?: string; // ISO8601 date that Grist will need reactivation.
daysLeft?: number; // Number of days until Grist will need reactivation.
}
}
export interface Activation extends ActivationState {
isManager: boolean;
}
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
// non-zero length.
const subdomainRegex = /^[-a-z0-9]+$/i;

@ -4,11 +4,9 @@ import {encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, isOrgInPath
parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
import {getOrgUrlInfo} from 'app/common/gristUrls';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {BillingTask} from 'app/common/BillingAPI';
import {TEAM_FREE_PLAN, TEAM_PLAN} from 'app/common/Features';
import {tbind} from 'app/common/tbind';
import * as version from 'app/common/version';
import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer';
import {ApiServer} from 'app/gen-server/ApiServer';
import {Document} from "app/gen-server/entity/Document";
import {Organization} from "app/gen-server/entity/Organization";
import {Workspace} from 'app/gen-server/entity/Workspace';
@ -48,8 +46,7 @@ import {IPermitStore} from 'app/server/lib/Permit';
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import {
adaptServerUrl, addOrgToPath, addPermit, getOrgUrl, getOriginUrl, getScope, optStringParam,
import {adaptServerUrl, addOrgToPath, getOrgUrl, getOriginUrl, getScope, optStringParam,
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils';
@ -616,7 +613,7 @@ export class FlexServer implements GristServer {
public addBillingApi() {
if (this._check('billing-api', 'homedb', 'json', 'api-mw')) { return; }
this._getBilling();
this._billing.addEndpoints(this.app, this);
this._billing.addEndpoints(this.app);
this._billing.addEventHandlers();
}
@ -1147,55 +1144,8 @@ export class FlexServer implements GristServer {
this._redirectToLoginWithoutExceptionsMiddleware
];
function getPrefix(req: express.Request) {
const org = getOrgFromRequest(req);
if (!org) {
return getOriginUrl(req);
}
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${org}` : '';
return prefix;
}
// Add billing summary page (.../billing)
this.app.get('/billing', ...middleware, expressWrap(async (req, resp, next) => {
const mreq = req as RequestWithLogin;
const orgDomain = mreq.org;
if (!orgDomain) {
return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}});
}
// Allow the support user access to billing pages.
const scope = addPermit(getScope(mreq), this._dbManager.getSupportUserId(), {org: orgDomain});
const query = await this._dbManager.getOrg(scope, orgDomain);
const org = this._dbManager.unwrapQueryResult(query);
// This page isn't available for personal site.
if (org.owner) {
return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}});
}
return this._sendAppPage(req, resp, {path: 'billing.html', status: 200, config: {}});
}));
this.app.get('/billing/payment', ...middleware, expressWrap(async (req, resp, next) => {
const task = (optStringParam(req.query.billingTask) || '') as BillingTask;
if (!BillingTask.guard(task)) {
// If the payment task are invalid, redirect to the summary page.
return resp.redirect(getOriginUrl(req) + `/billing`);
} else {
return this._sendAppPage(req, resp, {path: 'billing.html', status: 200, config: {}});
}
}));
// New landing page for the new NEW_DEAL.
this.app.get('/billing/create-team', ...middleware, expressWrap(async (req, resp, next) => {
const planType = optStringParam(req.query.planType) || '';
// Currently we have hardcoded support only for those two plans.
const supportedPlans = [TEAM_PLAN, TEAM_FREE_PLAN];
if (!supportedPlans.includes(planType)) {
return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}});
}
// Redirect to home page with url params
const url = `${getPrefix(req)}/?planType=${planType}#create-team`;
return resp.redirect(url);
}));
this._getBilling();
this._billing.addPages(this.app, middleware);
}
/**

@ -1,9 +1,9 @@
import * as express from 'express';
import {GristServer} from 'app/server/lib/GristServer';
export interface IBilling {
addEndpoints(app: express.Express, server: GristServer): void;
addEndpoints(app: express.Express): void;
addEventHandlers(): void;
addWebhooks(app: express.Express): void;
addMiddleware?(app: express.Express): Promise<void>;
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
}

@ -7,7 +7,6 @@ import {INotifier} from 'app/server/lib/INotifier';
import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox';
import {IShell} from 'app/server/lib/IShell';
import {createSandbox} from 'app/server/lib/NSandbox';
import * as express from 'express';
export interface ICreate {
@ -48,35 +47,37 @@ export interface ICreateNotifierOptions {
create(dbManager: HomeDBManager, gristConfig: GristServer): INotifier|undefined;
}
export interface ICreateBillingOptions {
create(dbManager: HomeDBManager, gristConfig: GristServer): IBilling|undefined;
}
export function makeSimpleCreator(opts: {
sessionSecret?: string,
storage?: ICreateStorageOptions[],
activationMiddleware?: (db: HomeDBManager, app: express.Express) => Promise<void>,
billing?: ICreateBillingOptions,
notifier?: ICreateNotifierOptions,
}): ICreate {
const {sessionSecret, storage, notifier, billing} = opts;
return {
Billing(db) {
return {
Billing(dbManager, gristConfig) {
return billing?.create(dbManager, gristConfig) ?? {
addEndpoints() { /* do nothing */ },
addEventHandlers() { /* do nothing */ },
addWebhooks() { /* do nothing */ },
async addMiddleware(app) {
// add activation middleware, if needed.
return opts?.activationMiddleware?.(db, app);
}
async addMiddleware() { /* do nothing */ },
addPages() { /* do nothing */ },
};
},
Notifier(dbManager, gristConfig) {
const {notifier} = opts;
return notifier?.create(dbManager, gristConfig) ?? {
get testPending() { return false; },
deleteUser() { throw new Error('deleteUser unavailable'); },
};
},
ExternalStorage(purpose, extraPrefix) {
for (const storage of opts.storage || []) {
if (storage.check()) {
return storage.create(purpose, extraPrefix);
for (const s of storage || []) {
if (s.check()) {
return s.create(purpose, extraPrefix);
}
}
return undefined;
@ -85,15 +86,15 @@ export function makeSimpleCreator(opts: {
return createSandbox('unsandboxed', options);
},
sessionSecret() {
const secret = process.env.GRIST_SESSION_SECRET || opts.sessionSecret;
const secret = process.env.GRIST_SESSION_SECRET || sessionSecret;
if (!secret) {
throw new Error('need GRIST_SESSION_SECRET');
}
return secret;
},
async configure() {
for (const storage of opts.storage || []) {
if (storage.check()) { break; }
for (const s of storage || []) {
if (s.check()) { break; }
}
},
getExtraHeadHtml() {

@ -54,7 +54,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL),
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: (mreq as RequestWithLogin|undefined)?.activation,
activation: getActivation(req as RequestWithLogin | undefined),
...extra,
};
}
@ -184,3 +184,11 @@ function getDocFromConfig(config: GristLoadConfig): Document | null {
return config.getDoc[config.assignmentId] ?? null;
}
function getActivation(mreq: RequestWithLogin|undefined) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
return {
...mreq?.activation,
isManager: Boolean(defaultEmail && defaultEmail === mreq?.user?.loginEmail),
};
}

@ -8,6 +8,8 @@ module.exports = {
main: "app/client/app",
errorPages: "app/client/errorMain",
account: "app/client/accountMain",
billing: "app/client/billingMain",
activation: "app/client/activationMain",
},
output: {
filename: "[name].bundle.js",

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<link rel="stylesheet" href="icons/icons.css">
<!-- INSERT CUSTOM -->
<title>Activation<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<!-- INSERT ERROR -->
<!-- INSERT CONFIG -->
<script src="activation.bundle.js"></script>
</body>
</html>

@ -0,0 +1,12 @@
import {AppModel} from 'app/client/models/AppModel';
import {Disposable} from 'grainjs';
export class ActivationPage extends Disposable {
constructor(_appModel: AppModel) {
super();
}
public buildDom() {
return null;
}
}

@ -0,0 +1,16 @@
import {AppModel} from 'app/client/models/AppModel';
import {DomElementArg} from 'grainjs';
export function buildUserMenuBillingItem(
_appModel: AppModel,
..._args: DomElementArg[]
) {
return null;
}
export function buildAppMenuBillingItem(
_appModel: AppModel,
..._args: DomElementArg[]
) {
return null;
}

@ -0,0 +1,12 @@
import {AppModel} from 'app/client/models/AppModel';
import {Disposable} from 'grainjs';
export class BillingPage extends Disposable {
constructor(_appModel: AppModel) {
super();
}
public buildDom() {
return null;
}
}
Loading…
Cancel
Save