mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
5f17dd0a06
commit
ed37401b2c
5
app/client/activationMain.ts
Normal file
5
app/client/activationMain.ts
Normal file
@ -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));
|
5
app/client/billingMain.ts
Normal file
5
app/client/billingMain.ts
Normal file
@ -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;
|
||||
|
42
app/client/ui/inputs.ts
Normal file
42
app/client/ui/inputs.ts
Normal file
@ -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[];
|
||||
@ -565,14 +575,20 @@ export function getPageTitleSuffix(config?: GristLoadConfig) {
|
||||
export interface ActivationState {
|
||||
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.
|
||||
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",
|
||||
|
16
static/activation.html
Normal file
16
static/activation.html
Normal file
@ -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>
|
12
stubs/app/client/ui/ActivationPage.ts
Normal file
12
stubs/app/client/ui/ActivationPage.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
16
stubs/app/client/ui/BillingButtons.ts
Normal file
16
stubs/app/client/ui/BillingButtons.ts
Normal file
@ -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;
|
||||
}
|
12
stubs/app/client/ui/BillingPage.ts
Normal file
12
stubs/app/client/ui/BillingPage.ts
Normal file
@ -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…
Reference in New Issue
Block a user