mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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[];
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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