diff --git a/app/client/lib/Validator.ts b/app/client/lib/Validator.ts new file mode 100644 index 00000000..4c4beec0 --- /dev/null +++ b/app/client/lib/Validator.ts @@ -0,0 +1,121 @@ +import { Disposable, dom, Observable, styled } from 'grainjs'; +import { colors } from 'app/client/ui2018/cssVars'; + +/** + * Simple validation controls. Renders as a red text with a validation message. + * + * Sample usage: + * + * const group = new ValidationGroup(); + * async function save() { + * if (await group.validate()) { + * api.save(....) + * } + * } + * .... + * dom('div', + * dom('Login', 'Enter login', input(login), group.resetInput()), + * dom.create(Validator, accountGroup, 'Login is required', () => Boolean(login.get()) === true)), + * dom.create(Validator, accountGroup, 'Login must by unique', async () => await throwsIfLoginIsTaken(login.get())), + * dom('button', dom.on('click', save)) + * ) + */ + +/** + * Validation function. Can return either boolean value or throw an error with a message that will be displayed + * in a validator instance. + */ +type ValidationFunction = () => (boolean | Promise | void | Promise) + +/** + * Validation groups allow you to organize validator controls on a page as a set. + * Each validation group can perform validation independently from other validation groups on the page. + */ +export class ValidationGroup { + // List of attached validators. + private _validators: Validator[] = []; + /** + * Runs all validators check functions. Returns result of the validation. + */ + public async validate() { + let valid = true; + for (const val of this._validators) { + try { + const result = await val.check(); + // Validator can either return boolean, Promise or void. Booleans are straightforwards. + // When validator has a void/Promise result it means that it just asserts certain invariant, and should + // throw an exception when this invariant is not met. Error message can be used to amend the message in the + // validator instance. + const isValid = typeof result === 'boolean' ? result : true; + val.set(isValid); + if (!isValid) { valid = false; break; } + } catch (err) { + valid = false; + val.set((err as Error).message); + break; + } + } + return valid; + } + /** + * Attaches single validator instance to this group. Validator can be in multiple groups + * at the same time. + */ + public add(validator: Validator) { + this._validators.push(validator); + } + /** + * Helper that can be attached to the input element to reset validation status. + */ + public inputReset() { + return dom.on('input', this.reset.bind(this)); + } + /** + * Reset all validators statuses. + */ + public reset() { + this._validators.forEach(val => val.set(true)); + } +} + +/** + * Validator instance. When triggered shows a red text with an error message. + */ +export class Validator extends Disposable { + private _isValid = Observable.create(this, true); + private _message = Observable.create(this, ''); + constructor(public group: ValidationGroup, message: string, public check: ValidationFunction) { + super(); + group.add(this); + this._message.set(message); + } + /** + * Helper that can be attached to the input element to reset validation status. + */ + public inputReset() { + return dom.on('input', this.set.bind(this, true)); + } + /** + * Sets the validation status. If isValid is a string it is treated as a falsy value, and will + * mark this validator as invalid. + */ + public set(isValid: boolean | string) { + if (this.isDisposed()) { return; } + if (typeof isValid === 'string') { + this._message.set(isValid); + this._isValid.set(!isValid); + } else { + this._isValid.set(isValid ? true : false); + } + } + public buildDom() { + return cssError( + dom.text(this._message), + dom.hide(this._isValid), + ); + } +} + +const cssError = styled('div.validator', ` + color: ${colors.error}; +`); diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index b0f7601b..02efbdcf 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -13,9 +13,10 @@ import {UserPrefs} from 'app/common/Prefs'; import {isOwner} from 'app/common/roles'; import {getTagManagerScript} from 'app/common/tagManager'; import {getGristConfig} from 'app/common/urlUtils'; -import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; +import {getOrgName, Organization, OrgError, SUPPORT_EMAIL, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; +import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades'; export {reportError} from 'app/client/models/errors'; @@ -73,8 +74,13 @@ export interface AppModel { pageType: Observable; notifier: Notifier; + planName: string|null; refreshOrgUsage(): Promise; + showUpgradeModal(): void; + showNewSiteModal(): void; + isBillingManager(): boolean; // If user is a billing manager for this org + isSupport(): boolean; // If user is a Support user } export class TopAppModelImpl extends Disposable implements TopAppModel { @@ -213,6 +219,30 @@ export class AppModelImpl extends Disposable implements AppModel { this._recordSignUpIfIsNewUser(); } + public get planName() { + return this.currentOrg?.billingAccount?.product.name ?? null; + } + + public async showUpgradeModal() { + if (this.planName && this.currentOrg) { + buildUpgradeModal(this, this.planName); + } + } + + public async showNewSiteModal() { + if (this.planName) { + buildNewSiteModal(this, this.planName); + } + } + + public isSupport() { + return this.currentValidUser?.email === SUPPORT_EMAIL; + } + + public isBillingManager() { + return Boolean(this.currentOrg?.billingAccount?.isManager); + } + /** * Fetch and update the current org's usage. */ diff --git a/app/client/models/BillingModel.ts b/app/client/models/BillingModel.ts index d9abcbbc..d14c4e60 100644 --- a/app/client/models/BillingModel.ts +++ b/app/client/models/BillingModel.ts @@ -1,12 +1,10 @@ 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, IBillingCoupon} from 'app/common/BillingAPI'; -import {IBillingCard, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI'; +import {BillingAPI, BillingAPIImpl, BillingSubPage, + BillingTask, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI'; import {FullUser} from 'app/common/LoginSessionAPI'; import {bundleChanges, Computed, Disposable, Observable} from 'grainjs'; -import isEqual = require('lodash/isEqual'); -import omit = require('lodash/omit'); export interface BillingModel { readonly error: Observable; @@ -15,8 +13,6 @@ export interface BillingModel { // Client-friendly version of the IBillingSubscription fetched from the server. // See ISubscriptionModel for details. readonly subscription: Observable; - // Payment card fetched from the server. - readonly card: Observable; readonly currentSubpage: Computed; // The billingTask query param of the url - indicates the current operation, if any. @@ -29,8 +25,6 @@ export interface BillingModel { // Indicates whether the request for billing account information fails with unauthorized. // Initialized to false until the request is made. readonly isUnauthorized: Observable; - // The tax rate to use for the sign up charge. Initialized by calling fetchSignupTaxRate. - signupTaxRate: number|undefined; reportBlockingError(this: void, err: Error): void; @@ -40,25 +34,25 @@ export interface BillingModel { addManager(email: string): Promise; // Remove billing account manager. removeManager(email: string): Promise; - // Remove the payment card from the account. - removeCard(): Promise; // Returns a boolean indicating if the org domain string is available. isDomainAvailable(domain: string): Promise; - // 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; - // Fetches coupon information for a valid `discountCode`. - fetchSignupCoupon(discountCode: string): Promise; - // Fetches the effective tax rate for the address in the given form. - fetchSignupTaxRate(formData: IFormData): Promise; // 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; + // 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; + // Cancels current subscription. + cancelCurrentPlan(): Promise; + // Retrieves customer portal session URL. + getCustomerPortalUrl(): string; + // Renews plan (either by opening customer portal or creating Stripe Checkout session) + renewPlan(): string; } -export interface ISubscriptionModel extends Omit { +export interface ISubscriptionModel extends IBillingSubscription { // The active plan. - activePlan: IBillingPlan; + activePlan: IBillingPlan|null; // The upcoming plan, or null if the current plan is not set to end. upcomingPlan: IBillingPlan|null; } @@ -73,8 +67,6 @@ export class BillingModelImpl extends Disposable implements BillingModel { // Client-friendly version of the IBillingSubscription fetched from the server. // See ISubscriptionModel for details. public readonly subscription: Observable = Observable.create(this, undefined); - // Payment card fetched from the server. - public readonly card: Observable = Observable.create(this, null); public readonly currentSubpage: Computed = Computed.create(this, urlState().state, (use, s) => s.billing === 'billing' ? undefined : s.billing); @@ -88,8 +80,7 @@ export class BillingModelImpl extends Disposable implements BillingModel { // The plan to which the user is in process of signing up. public readonly signupPlan: Computed = Computed.create(this, this.plans, this.signupPlanId, (use, plans, pid) => plans.find(_p => _p.id === pid)); - // The tax rate to use for the sign up charge. Initialized by calling fetchSignupTaxRate. - public signupTaxRate: number|undefined; + // Indicates whether the request for billing account information fails with unauthorized. // Initialized to false until the request is made. @@ -121,107 +112,51 @@ export class BillingModelImpl extends Disposable implements BillingModel { }); } - // Remove the payment card from the account. - public async removeCard(): Promise { - try { - await this._billingAPI.removeCard(); - this.card.set(null); - } catch (err) { - reportError(err); - } - } - public isDomainAvailable(domain: string): Promise { return this._billingAPI.isDomainAvailable(domain); } - public async fetchSignupCoupon(discountCode: string): Promise { - return await this._billingAPI.getCoupon(discountCode); + public getCustomerPortalUrl() { + return this._billingAPI.customerPortal(); + } + + public renewPlan() { + return this._billingAPI.renewPlan(); + } + + public async cancelCurrentPlan() { + const data = await this._billingAPI.cancelCurrentPlan(); + return data; } public async submitPaymentPage(formData: IFormData = {}): Promise { const task = this.currentTask.get(); - const planId = this.signupPlanId.get(); // TODO: The server should prevent most of the errors in this function from occurring by // redirecting improper urls. try { - if (task === 'signUp') { - // Sign up from an unpaid plan to a paid plan. - if (!planId) { throw new Error('BillingPage _submit error: no plan selected'); } - if (!formData.token) { throw new Error('BillingPage _submit error: no card submitted'); } - if (!formData.address) { throw new Error('BillingPage _submit error: no address submitted'); } - if (!formData.settings) { throw new Error('BillingPage _submit error: no settings submitted'); } - const {token, address, settings, coupon} = formData; - const o = await this._billingAPI.signUp(planId, token, address, settings, coupon?.promotion_code); - if (o && o.domain) { - await urlState().pushUrl({ org: o.domain, billing: 'billing', params: undefined }); - } else { - // TODO: show problems nicely - throw new Error('BillingPage _submit error: problem creating new organization'); + 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); } - } else { - // Any task after sign up. - if (task === 'updatePlan') { - // Change plan from a paid plan to another paid plan or to the free plan. - if (!planId) { throw new Error('BillingPage _submit error: no plan selected'); } - await this._billingAPI.setSubscription(planId, {tokenId: formData.token}); - } else if (task === 'addCard' || task === 'updateCard') { - // Add or update payment card. - if (!formData.token) { throw new Error('BillingPage _submit error: missing card info token'); } - await this._billingAPI.setCard(formData.token); - } else if (task === 'updateAddress') { - const org = this._appModel.currentOrg; - const sub = this.subscription.get(); - const name = formData.settings && formData.settings.name; - // Get the values of the new address and settings if they have changed. - const newAddr = sub && !isEqual(formData.address, sub.address) && formData.address; - const newSettings = org && (name !== org.name) && formData.settings; - // If the address or settings have a new value, run the update. - if (newAddr || newSettings) { - await this._billingAPI.updateAddress(newAddr || undefined, newSettings || undefined); - } - // If there is an org update, re-initialize the org in the client. - if (newSettings) { this._appModel.topAppModel.initialize(); } - } else 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.updateAddress(undefined, 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'); + // If the domain has changed, should redirect page. + if (newDomain) { + window.location.assign(urlState().makeUrl({ org: domain, billing: 'billing', params: undefined })); + return; } - // 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; - } - } - - public async fetchSignupTaxRate(formData: IFormData): Promise { - try { - if (this.currentTask.get() !== 'signUp') { - throw new Error('fetchSignupTaxRate only available during signup'); - } - if (!formData.address) { - throw new Error('Signup form data must include address'); + // 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'); } - this.signupTaxRate = await this._billingAPI.getTaxRate(formData.address); + // 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); @@ -231,13 +166,8 @@ export class BillingModelImpl extends Disposable implements BillingModel { // If forceReload is set, re-fetches and updates already fetched data. public async fetchData(forceReload: boolean = false): Promise { - if (this.currentSubpage.get() === 'plans' && !this._appModel.currentOrg) { - // If these are billing sign up pages, fetch the plan options only. - await this._fetchPlans(); - } else { - // If these are billing settings pages for an existing org, fetch the subscription data. - await this._fetchSubscription(forceReload); - } + // If these are billing settings pages for an existing org, fetch the subscription data. + await this._fetchSubscription(forceReload); } private _reportBlockingError(err: Error) { @@ -260,10 +190,9 @@ export class BillingModelImpl extends Disposable implements BillingModel { const subModel: ISubscriptionModel = { activePlan: sub.plans[sub.planIndex], upcomingPlan: sub.upcomingPlanIndex !== sub.planIndex ? sub.plans[sub.upcomingPlanIndex] : null, - ...omit(sub, 'plans', 'card'), + ...sub }; this.subscription.set(subModel); - this.card.set(sub.card); // Clear the fetch errors on success. this.isUnauthorized.set(false); this.error.set(null); @@ -274,11 +203,4 @@ export class BillingModelImpl extends Disposable implements BillingModel { } } } - - // Fetches the plans only - used when the billing pages are not associated with an org. - private async _fetchPlans(): Promise { - if (this.plans.get().length === 0) { - this.plans.set(await this._billingAPI.getPlans()); - } - } } diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 6c7133b6..3eabd59d 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -108,7 +108,7 @@ export class AccountWidget extends Disposable { menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') : menuItem(() => null, 'Billing Account', dom.cls('disabled', true)) ) : - menuItemLink({href: commonUrls.plans}, 'Upgrade Plan'), + menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'), mobileModeToggle, diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index 3a639b3e..8c659d6d 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -6,7 +6,7 @@ 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'; -import {isTemplatesOrg, Organization, SUPPORT_EMAIL} from 'app/common/UserAPI'; +import {isTemplatesOrg, Organization} from 'app/common/UserAPI'; import {AppModel} from 'app/client/models/AppModel'; import {icon} from 'app/client/ui2018/icons'; import {DocPageModel} from 'app/client/models/DocPageModel'; @@ -35,10 +35,8 @@ export class AppHeader extends Disposable { public buildDom() { const theme = getTheme(this._appModel.topAppModel.productFlavor); - const user = this._appModel.currentValidUser; const currentOrg = this._appModel.currentOrg; - const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount && - (currentOrg.billingAccount.isManager || user?.email === SUPPORT_EMAIL)); + const isBillingManager = this._appModel.isBillingManager() || this._appModel.isSupport(); return cssAppHeader( cssAppHeader.cls('-widelogo', theme.wideLogo || false), diff --git a/app/client/ui/BillingForm.ts b/app/client/ui/BillingForm.ts index 344e54ce..b7ca87f1 100644 --- a/app/client/ui/BillingForm.ts +++ b/app/client/ui/BillingForm.ts @@ -1,44 +1,21 @@ -import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; -import {reportError} from 'app/client/models/AppModel'; import {BillingModel} from 'app/client/models/BillingModel'; import * as css from 'app/client/ui/BillingPageCss'; -import {colors, vars} from 'app/client/ui2018/cssVars'; -import {IOption, select} from 'app/client/ui2018/menus'; -import type {ApiError} from 'app/common/ApiError'; -import {IBillingAddress, IBillingCard, IBillingCoupon, IBillingOrgSettings, - IFilledBillingAddress} from 'app/common/BillingAPI'; +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 {Computed, Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable, styled} from 'grainjs'; -import sortBy = require('lodash/sortBy'); +import {Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable} from 'grainjs'; -const G = getBrowserGlobals('Stripe', 'window'); const testId = makeTestId('test-bp-'); -const states = [ - 'AK', 'AL', 'AR', 'AS', 'AZ', 'CA', 'CO', 'CT', 'DC', 'DE', 'FL', 'FM', 'GA', 'GU', 'HI', - 'IA', 'ID', 'IL', 'IN', 'KS', 'KY', 'LA', 'MA', 'MD', 'ME', 'MH', 'MI', 'MN', 'MO', 'MP', - 'MS', 'MT', 'NC', 'ND', 'NE', 'NH', 'NJ', 'NM', 'NV', 'NY', 'OH', 'OK', 'OR', 'PA', 'PR', - 'PW', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VA', 'VI', 'VT', 'WA', 'WI', 'WV', 'WY' -]; export interface IFormData { - address?: IFilledBillingAddress; - card?: IBillingCard; - token?: string; settings?: IBillingOrgSettings; - coupon?: IBillingCoupon; } // Optional autofill vales to pass in to the BillingForm constructor. interface IAutofill { - address?: Partial; settings?: Partial; - // Note that the card name is the only value that may be initialized, since the other card - // information is sensitive. - card?: Partial; - coupon?: Partial; } // An object containing a function to check the validity of its observable value. @@ -51,69 +28,34 @@ interface IValidated { } export class BillingForm extends Disposable { - private readonly _address: BillingAddressForm|null; - private readonly _discount: BillingDiscountForm|null; - private readonly _payment: BillingPaymentForm|null; private readonly _settings: BillingSettingsForm|null; constructor( org: Organization|null, billingModel: BillingModel, - options: {payment: boolean, address: boolean, settings: boolean, domain: boolean, discount: boolean}, + options: {settings: boolean, domain: boolean}, autofill: IAutofill = {} ) { super(); - - // Get the number of forms - if more than one is present subheaders should be visible. - const count = [options.settings, options.address, options.payment] - .reduce((acc, x) => acc + (x ? 1 : 0), 0); - // Org settings form. this._settings = options.settings ? new BillingSettingsForm(billingModel, org, { - showHeader: count > 1, + showHeader: true, showDomain: options.domain, autofill: autofill.settings }) : null; - - // Discount form. - this._discount = options.discount ? new BillingDiscountForm(billingModel, { - autofill: autofill.coupon - }) : null; - - // Address form. - this._address = options.address ? new BillingAddressForm({ - showHeader: count > 1, - autofill: autofill.address - }) : null; - - // Payment form. - this._payment = options.payment ? new BillingPaymentForm({ - showHeader: count > 1, - autofill: autofill.card - }) : null; } public buildDom() { return [ this._settings ? this._settings.buildDom() : null, - this._discount ? this._discount.buildDom() : null, - this._address ? this._address.buildDom() : null, - this._payment ? this._payment.buildDom() : null ]; } // Note that this will throw if any values are invalid. public async getFormData(): Promise { const settings = this._settings ? await this._settings.getSettings() : undefined; - const address = this._address ? await this._address.getAddress() : undefined; - const cardInfo = this._payment ? await this._payment.getCardAndToken() : undefined; - const coupon = this._discount ? await this._discount.getCoupon() : undefined; return { settings, - address, - coupon, - token: cardInfo ? cardInfo.token : undefined, - card: cardInfo ? cardInfo.card : undefined }; } @@ -173,233 +115,6 @@ abstract class BillingSubForm extends Disposable { } } -/** - * Creates the payment card entry form using Stripe Elements. - */ -class BillingPaymentForm extends BillingSubForm { - private readonly _stripe: any; - private readonly _elements: any; - - // Stripe Element fields. Set when the elements are mounted to the dom. - private readonly _numberElement: Observable = Observable.create(this, null); - private readonly _expiryElement: Observable = Observable.create(this, null); - private readonly _cvcElement: Observable = Observable.create(this, null); - private readonly _name: IValidated = createValidated(this, checkRequired('Name')); - - constructor(private readonly _options: { - showHeader: boolean; - autofill?: Partial; - }) { - super(); - const autofill = this._options.autofill; - const stripeAPIKey = G.window.gristConfig.stripeAPIKey; - try { - this._stripe = G.Stripe(stripeAPIKey); - this._elements = this._stripe.elements(); - } catch (err) { - reportError(err); - } - if (autofill) { - this._name.value.set(autofill.name || ''); - } - } - - public buildDom() { - return this._stripe ? css.paymentBlock( - this._options.showHeader ? css.paymentSubHeader('Payment Method') : null, - css.paymentRow( - css.paymentField( - css.paymentLabel('Cardholder Name'), - this.billingInput(this._name, testId('card-name')), - ) - ), - css.paymentRow( - css.paymentField( - css.paymentLabel({for: 'number-element'}, 'Card Number'), - css.stripeInput({id: 'number-element'}), // A Stripe Element will be inserted here. - testId('card-number') - ) - ), - css.paymentRow( - css.paymentField( - css.paymentLabel({for: 'expiry-element'}, 'Expiry Date'), - css.stripeInput({id: 'expiry-element'}), // A Stripe Element will be inserted here. - testId('card-expiry') - ), - css.paymentSpacer(), - css.paymentField( - css.paymentLabel({for: 'cvc-element'}, 'CVC / CVV Code'), - css.stripeInput({id: 'cvc-element'}), // A Stripe Element will be inserted here. - testId('card-cvc') - ) - ), - css.inputError( - dom.text(this.formError), - testId('payment-form-error') - ), - () => { setTimeout(() => this._mountStripeUI(), 0); } - ) : null; - } - - public async getCardAndToken(): Promise<{card: IBillingCard, token: string}> { - // Note that we call createToken using only the card number element as the first argument - // in accordance with the Stripe API: - // - // "If applicable, the Element pulls data from other Elements you’ve created on the same - // instance of elements to tokenize—you only need to supply one element as the parameter." - // - // Source: https://stripe.com/docs/stripe-js/reference#stripe-create-token - try { - const result = await this._stripe.createToken(this._numberElement.get(), {name: await this._name.get()}); - if (result.error) { throw new Error(result.error.message); } - return { - card: result.token.card, - token: result.token.id - }; - } catch (e) { - this.formError.set(e.message); - throw e; - } - } - - private _mountStripeUI() { - // Mount Stripe Element fields. - this._mountStripeElement(this._numberElement, 'cardNumber', 'number-element'); - this._mountStripeElement(this._expiryElement, 'cardExpiry', 'expiry-element'); - this._mountStripeElement(this._cvcElement, 'cardCvc', 'cvc-element'); - } - - private _mountStripeElement(elemObs: Observable, stripeName: string, elementId: string): void { - // For details on applying custom styles to Stripe Elements, see: - // https://stripe.com/docs/stripe-js/reference#element-options - const classes = {base: css.stripeInput.className}; - const style = { - base: { - '::placeholder': { - color: colors.slate.value - }, - 'fontSize': vars.mediumFontSize.value, - 'fontFamily': vars.fontFamily.value - } - }; - if (!elemObs.get()) { - const stripeInst = this._elements.create(stripeName, {classes, style}); - stripeInst.addEventListener('change', (event: any) => { - if (event.error) { this.formError.set(event.error.message); } - }); - elemObs.set(stripeInst); - } - elemObs.get().mount(`#${elementId}`); - } -} - -/** - * Creates the company address entry form. Used by BillingPaymentForm when billing address is needed. - */ -class BillingAddressForm extends BillingSubForm { - private readonly _address1: IValidated = createValidated(this, checkRequired('Address')); - private readonly _address2: IValidated = createValidated(this, () => undefined); - private readonly _city: IValidated = createValidated(this, checkRequired('City')); - private readonly _state: IValidated = createValidated(this, checkFunc( - (val) => !this._isUS.get() || Boolean(val), `State is required.`)); - private readonly _postal: IValidated = createValidated(this, checkFunc( - (val) => !this._isUS.get() || Boolean(val), 'Zip code is required.')); - private readonly _countryCode: IValidated = createValidated(this, checkRequired('Country')); - - private _isUS = Computed.create(this, this._countryCode.value, (use, code) => (code === 'US')); - - private readonly _countries: Array> = getCountries(); - - constructor(private readonly _options: { - showHeader: boolean; - autofill?: Partial; - }) { - super(); - const autofill = this._options.autofill; - if (autofill) { - this._address1.value.set(autofill.line1 || ''); - this._address2.value.set(autofill.line2 || ''); - this._city.value.set(autofill.city || ''); - this._state.value.set(autofill.state || ''); - this._postal.value.set(autofill.postal_code || ''); - } - this._countryCode.value.set(autofill?.country || 'US'); - } - - public buildDom() { - return css.paymentBlock( - this._options.showHeader ? css.paymentSubHeader('Company Address') : null, - css.paymentRow( - css.paymentField( - css.paymentLabel('Street Address'), - this.billingInput(this._address1, testId('address-street')) - ) - ), - css.paymentRow( - css.paymentField( - css.paymentLabel('Suite / Unit'), - this.billingInput(this._address2, testId('address-suite')) - ) - ), - css.paymentRow( - css.paymentField( - css.paymentLabel('City'), - this.billingInput(this._city, testId('address-city')) - ), - css.paymentSpacer(), - css.paymentField({style: 'flex: 0.5 1 0;'}, - dom.domComputed(this._isUS, (isUs) => - isUs ? [ - css.paymentLabel('State'), - cssSelect(this._state.value, states), - ] : [ - css.paymentLabel('State / Region'), - this.billingInput(this._state), - ] - ), - testId('address-state') - ) - ), - css.paymentRow( - css.paymentField( - css.paymentLabel(dom.text((use) => use(this._isUS) ? 'Zip Code' : 'Postal Code')), - this.billingInput(this._postal, testId('address-zip')) - ) - ), - css.paymentRow( - css.paymentField( - css.paymentLabel('Country'), - cssSelect(this._countryCode.value, this._countries), - testId('address-country') - ) - ), - css.inputError( - dom.text(this.formError), - testId('address-form-error') - ) - ); - } - - // Throws if any value is invalid. Returns a customer address as accepted by the customer - // object in stripe. - // For reference: https://stripe.com/docs/api/customers/object#customer_object-address - public async getAddress(): Promise { - try { - return { - line1: await this._address1.get(), - line2: await this._address2.get(), - city: await this._city.get(), - state: await this._state.get(), - postal_code: await this._postal.get(), - country: await this._countryCode.get(), - }; - } catch (e) { - this.formError.set(e.message); - throw e; - } - } -} - /** * Creates the billing settings form, including the org name and the org subdomain values. */ @@ -430,7 +145,7 @@ class BillingSettingsForm extends BillingSubForm { const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access)); const initDomain = this._options.autofill?.domain; return css.paymentBlock( - this._options.showHeader ? css.paymentSubHeader('Team Site') : null, + this._options.showHeader ? css.paymentLabel('Team name') : null, css.paymentRow( css.paymentField( this.billingInput(this._name, @@ -444,7 +159,7 @@ class BillingSettingsForm extends BillingSubForm { ), this._options.showDomain ? css.paymentRow( css.paymentField( - css.paymentLabel('URL'), + css.paymentLabel('Team subdomain'), this.billingInput(this._domain, dom.boolAttr('disabled', () => noEditAccess), testId('settings-domain') @@ -490,67 +205,6 @@ class BillingSettingsForm extends BillingSubForm { } } -/** - * Creates the billing discount form. - */ -class BillingDiscountForm extends BillingSubForm { - private _isExpanded = Observable.create(this, false); - private readonly _discountCode: IValidated = createValidated(this, () => undefined); - - constructor( - private readonly _billingModel: BillingModel, - private readonly _options: { autofill?: Partial; } - ) { - super(); - if (this._options.autofill) { - const { promotion_code } = this._options.autofill; - this._discountCode.value.set(promotion_code ?? ''); - this._isExpanded.set(Boolean(promotion_code)); - } - } - - public buildDom() { - return dom.domComputed(this._isExpanded, isExpanded => [ - !isExpanded ? - css.paymentBlock( - css.paymentRow( - css.billingText('Have a discount code?', testId('discount-code-question')), - css.billingTextBtn( - css.billingIcon('Settings'), - 'Apply', - dom.on('click', () => { this.shouldAutoFocus = true; this._isExpanded.set(true); }), - testId('apply-discount-code') - ) - ) - ) : - css.paymentBlock( - css.paymentRow( - css.paymentField( - css.paymentLabel('Discount Code'), - this.billingInput(this._discountCode, testId('discount-code'), this.maybeAutoFocus()), - ) - ), - css.inputError( - dom.text(this.formError), - testId('discount-form-error') - ) - ) - ]); - } - - public async getCoupon() { - const discountCode = await this._discountCode.get(); - if (discountCode.trim() === '') { return undefined; } - - try { - return await this._billingModel.fetchSignupCoupon(discountCode); - } catch (e) { - const message = (e as ApiError).details?.userError; - this.formError.set(message || 'Invalid or expired discount code.'); - throw e; - } - } -} function checkFunc(func: (val: string) => boolean, message: string) { return (val: string) => { @@ -588,23 +242,3 @@ function createValidated( } }; } - -function getCountries(): Array> { - // Require just the one file because it has all the data we need and is substantially smaller - // than requiring the whole module. - const countryNames = require("i18n-iso-countries/langs/en.json").countries; - const codes = Object.keys(countryNames); - const entries = codes.map(code => { - // The module provides names that are either a string or an array of names. If an array, pick - // the first one. - const names = countryNames[code]; - return {value: code, label: Array.isArray(names) ? names[0] : names}; - }); - return sortBy(entries, 'label'); -} - -const cssSelect = styled(select, ` - height: 42px; - padding-left: 13px; - align-items: center; -`); diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index c20de0c2..377fdcdf 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -2,53 +2,41 @@ import {SiteUsageBanner} from 'app/client/components/SiteUsageBanner'; 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 {getLoginUrl, urlState} from 'app/client/models/gristUrlState'; +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 {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 {loadingSpinner} from 'app/client/ui2018/loaders'; -import {confirmModal} from 'app/client/ui2018/modals'; -import {BillingSubPage, BillingTask, IBillingAddress, IBillingCard, IBillingCoupon, - IBillingPlan} from 'app/common/BillingAPI'; -import {capitalize} from 'app/common/gutil'; -import {Organization} from 'app/common/UserAPI'; -import {Disposable, dom, IAttrObj, IDomArgs, makeTestId, Observable} from 'grainjs'; +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 { 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 { loadingSpinner } from 'app/client/ui2018/loaders'; +import { BillingTask, IBillingCoupon } from 'app/common/BillingAPI'; +import { capitalize } from 'app/common/gutil'; +import { Organization } from 'app/common/UserAPI'; +import { Disposable, dom, DomArg, IAttrObj, makeTestId, Observable } from 'grainjs'; +import { IconName } from '../ui2018/IconList'; const testId = makeTestId('test-bp-'); -const taskActions = { - signUp: 'Sign Up', - updatePlan: 'Update Plan', - addCard: 'Add Payment Method', - updateCard: 'Update Payment Method', - updateAddress: 'Update Address', - signUpLite: 'Complete Sign Up', - updateDomain: 'Update Name', +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 }; /** - * Creates the billing page where a user can manager their subscription and payment card. + * Creates the billing page where users can manage their subscription and payment card. */ export class BillingPage extends Disposable { - - private readonly _model: BillingModel = new BillingModelImpl(this._appModel); - - private _form: BillingForm|undefined = undefined; + private _model: BillingModel = new BillingModelImpl(this._appModel); + private _form: BillingForm | undefined = undefined; private _formData: IFormData = {}; - - // Indicates whether the payment page is showing the confirmation page or the data entry form. - // If _showConfirmation includes the entered form data, the confirmation page is shown. - // A null value indicates the data entry form is being shown. - private readonly _showConfirmPage: Observable = Observable.create(this, false); - - // Indicates that the payment page submit button has been clicked to prevent repeat requests. - private readonly _isSubmitting: Observable = Observable.create(this, false); + private _showConfirmPage: Observable = Observable.create(this, false); + private _isSubmitting: Observable = Observable.create(this, false); constructor(private _appModel: AppModel) { super(); @@ -56,6 +44,11 @@ export class BillingPage extends Disposable { this._appModel.refreshOrgUsage().catch(reportError); } + // Exposed for tests. + public testBuildPaymentPage() { + return this._buildPaymentPage(); + } + public buildDom() { return dom.domComputed(this._model.isUnauthorized, (isUnauthorized) => { if (isUnauthorized) { @@ -73,8 +66,8 @@ export class BillingPage extends Disposable { content: leftPanelBasic(this._appModel, panelOpen), }, headerMain: this._createTopBarBilling(), - contentMain: this.buildCurrentPageDom(), contentTop: dom.create(SiteUsageBanner, this._appModel), + contentMain: this._buildCurrentPageDom() }); } }); @@ -83,222 +76,69 @@ export class BillingPage extends Disposable { /** * Builds the contentMain dom for the current billing page. */ - public buildCurrentPageDom() { + private _buildCurrentPageDom() { return css.billingWrapper( dom.domComputed(this._model.currentSubpage, (subpage) => { if (!subpage) { - return this.buildSummaryPage(); + return this._buildSummaryPage(); } else if (subpage === 'payment') { - return this.buildPaymentPage(); - } else if (subpage === 'plans') { - return this.buildPlansPage(); + return this._buildPaymentPage(); } }) ); } - public buildSummaryPage() { + private _buildSummaryPage() { const org = this._appModel.currentOrg; // Fetch plan and card data. this._model.fetchData(true).catch(reportError); return css.billingPage( - css.cardBlock( - css.billingHeader('Account'), - dom.domComputed(this._model.subscription, sub => [ - this._buildDomainSummary(org && org.domain), - this._buildCompanySummary(org && org.name, sub ? (sub.address || {}) : null), - dom.domComputed(this._model.card, card => - this._buildCardSummary(card, !sub, [ - css.billingTextBtn(css.billingIcon('Settings'), 'Change', - urlState().setLinkUrl({ - billing: 'payment', - params: { billingTask: 'updateCard' } - }), - testId('update-card') - ), - css.billingTextBtn(css.billingIcon('Remove'), 'Remove', - dom.on('click', () => this._showRemoveCardModal()), - testId('remove-card') - ) - ]) - ) - ]), - // 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.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); + } + }) ), - dom.create(BillingPlanManagers, this._model, org, this._appModel.currentValidUser) - ) : null - ), - css.summaryBlock( - css.billingHeader('Billing Summary'), - this.buildSubscriptionSummary(), - ) - ); - } - - public buildSubscriptionSummary() { - return dom.maybe(this._model.subscription, sub => { - const plans = this._model.plans.get(); - 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 planId = validPlan ? sub.activePlan.id : sub.lastPlanId; - // If on a "Tier" coupon, present information differently, emphasizing the coupon - // name and minimizing the plan. - const discountName = sub.discount && sub.discount.name; - const discountEnd = sub.discount && sub.discount.end_timestamp_ms; - const tier = discountName && discountName.includes(' Tier '); - const planName = tier ? discountName! : sub.activePlan.nickname; - const invoiceId = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId; - return [ - css.summaryFeatures( - validPlan ? [ - makeSummaryFeature(['You are subscribed to the ', planName, ' plan']), - ] : [ - 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 ? [ - 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 ? [ - makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]), - makeSummaryFeature(['On this date, your team site will become ', 'read-only', - ' for one month, then removed']) - ] : null, - moneyPlan.amount ? [ - makeSummaryFeature([`Your team site has `, `${sub.userCount}`, - ` member${sub.userCount !== 1 ? 's' : ''}`]), - tier ? this.buildAppSumoPlanNotes(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, - invoiceId ? this.buildAppSumoLink(invoiceId) : null, - getSubscriptionProblem(sub), - testId('summary') - ), - (sub.lastInvoiceUrl ? - dom('div', - css.billingTextBtn({ style: 'margin: 10px 0;' }, - cssBreadcrumbsLink( - css.billingIcon('Page'), 'View last invoice', - { href: sub.lastInvoiceUrl, target: '_blank' }, - testId('invoice-link') - ) - ) - ) : - null - ), - (moneyPlan.amount === 0 && planId) ? css.billingTextBtn( - { style: 'margin: 10px 0;' }, - // If the plan was cancellled, make the text indicate that changing the plan will - // renew the subscription (abort the cancellation). - css.billingIcon('Settings'), 'Renew subscription', - urlState().setLinkUrl({ - billing: 'payment', - params: { - billingTask: 'updatePlan', - billingPlan: planId - } - }), - testId('update-plan') - ) : null, - // Do not show the cancel subscription option if it was already cancelled. - plans.length > 0 && moneyPlan.amount > 0 ? css.billingTextBtn( - { style: 'margin: 10px 0;' }, - css.billingIcon('Settings'), 'Cancel subscription', - urlState().setLinkUrl({ - billing: 'payment', - params: { - billingTask: 'updatePlan', - billingPlan: plans[0].id - } - }), - testId('cancel-subscription') - ) : null - ]; - }); - } - - public buildAppSumoPlanNotes(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; - } - - // Include a precise link back to AppSumo for changing plans. - public buildAppSumoLink(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') - ) - )); - } - - public buildPlansPage() { - // Fetch plan and card data if not already present. - this._model.fetchData().catch(reportError); - return css.plansPage( - css.billingHeader('Choose a plan'), - css.billingText('Give your team the features they need to succeed'), - this._buildPlanCards() + css.summaryBlock( + css.billingHeader('Billing Summary'), + this._buildSubscriptionSummary(), + ) + ]; + }) ); } - public buildPaymentPage() { + // PRIVATE - exposed for tests + private _buildPaymentPage() { const org = this._appModel.currentOrg; - const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual; // 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 = taskActions[task]; + const pageText = billingTasksNames[task]; return [ css.cardBlock( css.billingHeader(pageText), @@ -308,9 +148,10 @@ export class BillingPage extends Disposable { return css.errorBox(err, dom('br'), dom('br'), reportLink(this._appModel, "Report problem")); } const sub = use(this._model.subscription); - const card = use(this._model.card); - const newPlan = use(this._model.signupPlan); - if (newPlan && newPlan.amount === 0) { + if (!sub) { + return css.spinnerBox(loadingSpinner()); + } + if (task === 'cancelPlan') { // If the selected plan is free, the user is cancelling their subscription. return [ css.paymentBlock( @@ -322,36 +163,17 @@ export class BillingPage extends Disposable { 'with all documents inside.' ), css.paymentBlock('Are you sure you would like to cancel the subscription?'), - this._buildPaymentBtns('Cancel Subscription') - ]; - } else if (sub && card && newPlan && sub.activePlan && newPlan.amount <= sub.activePlan.amount) { - // If the user already has a card entered and the plan costs less money than - // the current plan, show the card summary only (no payment required yet) - return [ - isSharedOrg ? this._buildDomainSummary(org && org.domain) : null, - this._buildCardSummary(card, !sub, [ - css.billingTextBtn(css.billingIcon('Settings'), 'Update Card', - // Clear the fetched card to display the card input form. - dom.on('click', () => this._model.card.set(null)), - testId('update-card') - ) - ]), - this._buildPaymentBtns(pageText) + this._buildButtons('Cancel Subscription') ]; - } else { + } else { // tasks - signUpLite return dom.domComputed(this._showConfirmPage, (showConfirm) => { if (showConfirm) { return [ - this._buildPaymentConfirmation(this._formData), - this._buildPaymentBtns(pageText) + this._buildConfirm(this._formData), + this._buildButtons(pageText) ]; - } else if (!sub) { - return css.spinnerBox(loadingSpinner()); - } else if (!newPlan && (task === 'signUp' || task === 'updatePlan')) { - return css.errorBox('Unknown plan selected. Please check the URL, or ', - reportLink(this._appModel, 'report this issue'), '.'); } else { - return this._buildBillingForm(org, sub.address, task); + return this._buildForms(org, task); } }); } @@ -369,31 +191,141 @@ export class BillingPage extends Disposable { ); } - private _buildBillingForm(org: Organization|null, address: IBillingAddress|null, task: BillingTask) { - const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual; - const currentSettings = isSharedOrg ? { + 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 ?? this._appModel.planName; + const planName = tier ? discountName : activePlanName; + const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId; + const isPaidPlan = sub.billable; + // If subscription is cancelled, 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 cancelled. + const canManage = !canRenew && isPaidPlan; + return [ + css.summaryFeatures( + validPlan && planName ? [ + makeSummaryFeature(['You are subscribed to the ', planName, ' plan']) + ] : [ + 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', + ' for one month, then removed']) + ] : 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')), + !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 _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, - } : this._formData.settings; - const currentAddress = address || this._formData.address; - const pageText = taskActions[task]; + }; + 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, { - payment: ['signUp', 'updatePlan', 'addCard', 'updateCard'].includes(task), - discount: ['signUp'].includes(task), - address: ['signUp', 'updateAddress'].includes(task), - settings: ['signUp', 'signUpLite', 'updateAddress', 'updateDomain'].includes(task), - domain: ['signUp', 'signUpLite', 'updateDomain'].includes(task) + settings: ['signUpLite', 'updateDomain'].includes(task), + domain: ['signUpLite', 'updateDomain'].includes(task) }, { - address: currentAddress, settings: currentSettings, - card: this._formData.card, - coupon: this._formData.coupon } ); return dom('div', @@ -403,18 +335,16 @@ export class BillingPage extends Disposable { this._form = undefined; } }), - isSharedOrg ? this._buildDomainSummary(org && org.domain) : null, + isTeamSite ? this._buildDomainSummary(currentSettings ?? {}) : null, this._form.buildDom(), - this._buildPaymentBtns(pageText) + this._buildButtons(pageText) ); } - private _buildPaymentConfirmation(formData: IFormData) { + private _buildConfirm(formData: IFormData) { const settings = formData.settings || null; return [ - this._buildDomainSummary(settings && settings.domain, false), - this._buildCompanySummary(settings && settings.name, formData.address || null, false), - this._buildCardSummary(formData.card || null) + this._buildDomainConfirmation(settings ?? {}, false), ]; } @@ -449,101 +379,29 @@ export class BillingPage extends Disposable { ); } - private _buildPlanCards() { - const org = this._appModel.currentOrg; - const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual; - const attr = {style: 'margin: 12px 0 12px 0;'}; // Feature attributes - return css.plansContainer( - dom.maybe(this._model.plans, (plans) => { - // Do not show the free plan inside the paid org plan options. - return plans.filter(plan => !isSharedOrg || plan.amount > 0).map(plan => { - const priceStr = plan.amount === 0 ? 'Free' : getPriceString(plan.amount); - const meta = plan.metadata; - const maxDocs = meta.maxDocs ? `up to ${meta.maxDocs}` : `unlimited`; - const maxUsers = meta.maxUsersPerDoc ? - `Share with ${meta.maxUsersPerDoc} collaborators per doc` : - `Share and collaborate with any number of team members`; - return css.planBox( - css.billingHeader(priceStr, { style: `display: inline-block;` }), - css.planInterval(plan.amount === 0 ? '' : `/ user / ${plan.interval}`), - css.billingSubHeader(plan.nickname), - makeSummaryFeature(`Create ${maxDocs} docs`, {attr}), - makeSummaryFeature(maxUsers, {attr}), - makeSummaryFeature('Workspaces to organize docs and users', { - isMissingFeature: !meta.workspaces, - attr - }), - makeSummaryFeature(`Access to support`, { - isMissingFeature: !meta.supportAvailable, - attr - }), - makeSummaryFeature(`Unthrottled API access`, { - isMissingFeature: !meta.unthrottledApi, - attr - }), - makeSummaryFeature(`Custom Grist subdomain`, { - isMissingFeature: !meta.customSubdomain, - attr - }), - plan.trial_period_days ? makeSummaryFeature(['', `${plan.trial_period_days} day free trial`], - {attr}) : css.summarySpacer(), - // Add the upgrade buttons once the user plan information has loaded - dom.domComputed(this._model.subscription, sub => { - const activePrice = sub ? sub.activePlan.amount : 0; - const selectedPlan = sub && (sub.upcomingPlan || sub.activePlan); - // URL state for the payment page to update the plan or sign up. - const payUrlState = { - billing: 'payment' as BillingSubPage, - params: { - billingTask: activePrice > 0 ? 'updatePlan' : 'signUp' as BillingTask, - billingPlan: plan.id - } - }; - if (!this._appModel.currentValidUser && plan.amount === 0) { - // If the user is not logged in and selects the free plan, provide a login link that - // redirects back to the free org. - return css.upgradeBtn('Sign up', - {href: getLoginUrl()}, - testId('plan-btn') - ); - } else if ((!selectedPlan && plan.amount === 0) || (selectedPlan && plan.id === selectedPlan.id)) { - return css.currentBtn('Current plan', - testId('plan-btn') - ); - } else { - // Sign up / update plan. - // Show 'Create' if this is not a paid org to indicate that an org will be created. - const upgradeText = isSharedOrg ? 'Upgrade' : 'Create team site'; - return css.upgradeBtn(plan.amount > activePrice ? upgradeText : 'Select', - urlState().setLinkUrl(payUrlState), - testId('plan-btn') - ); - } - }), - testId('plan') - ); - }); - }) - ); - } - - private _buildDomainSummary(domain: string|null, showEdit: boolean = true) { - const task = this._model.currentTask.get(); - if (task === 'signUpLite' || task === 'updateDomain') { return null; } + private _buildDomainConfirmation(org: { name?: string | null, domain?: string | null }, showEdit: boolean = true) { return css.summaryItem( css.summaryHeader( - css.billingBoldText('Billing Info'), + css.billingBoldText('Team Info'), ), - domain ? [ + 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'}, domain), + dom('span', { style: 'font-weight: bold' }, org?.domain), `.getgrist.com`, testId('org-domain') ), showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change', urlState().setLinkUrl({ - billing: 'payment', + billing: 'billing', params: { billingTask: 'updateDomain' } }), testId('update-domain') @@ -553,228 +411,48 @@ export class BillingPage extends Disposable { ); } - private _buildCompanySummary(orgName: string|null, address: Partial|null, showEdit: boolean = true) { - return css.summaryItem({style: 'min-height: 118px;'}, - css.summaryHeader( - css.billingBoldText(`Company Name & Address`), - showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change', - urlState().setLinkUrl({ - billing: 'payment', - params: { billingTask: 'updateAddress' } - }), - testId('update-address') - ) : null - ), - orgName && css.summaryRow( - css.billingText(orgName, - testId('org-name') - ) - ), - address ? [ - css.summaryRow( - css.billingText(address.line1, - testId('company-address-1') - ) - ), - address.line2 ? css.summaryRow( - css.billingText(address.line2, - testId('company-address-2') - ) - ) : null, - css.summaryRow( - css.billingText(formatCityStateZip(address), - testId('company-address-3') - ) - ), - address.country ? css.summaryRow( - // This show a 2-letter country code (e.g. "US" or "DE"). This seems fine. - css.billingText(address.country, - testId('company-address-country') - ) - ) : null, - ] : css.billingHintText('Fetching address...') - ); - } - - private _buildCardSummary(card: IBillingCard|null, fetching?: boolean, btns?: IDomArgs) { - if (fetching) { - // If the subscription data has not yet been fetched. - return css.summaryItem({style: 'min-height: 102px;'}, - css.summaryHeader( - css.billingBoldText(`Payment Card`), - ), - css.billingHintText('Fetching card preview...') - ); - } else if (card) { - // There is a card attached to the account. - const brand = card.brand ? `${card.brand.toUpperCase()} ` : ''; - return css.summaryItem( - css.summaryHeader( - css.billingBoldText( - // The header indicates the card type (Credit/Debit/Prepaid/Unknown) - `${capitalize(card.funding || 'payment')} Card`, - testId('card-funding') - ), - btns - ), - css.billingText(card.name, - testId('card-name') - ), - css.billingText(`${brand}**** **** **** ${card.last4}`, - testId('card-preview') - ) - ); - } else { - return css.summaryItem( - css.summaryHeader( - css.billingBoldText(`Payment Card`), - css.billingTextBtn(css.billingIcon('Settings'), 'Add', - urlState().setLinkUrl({ - billing: 'payment', - params: { billingTask: 'addCard' } - }), - testId('add-card') - ), - ), - // TODO: Warn user when a payment method will be required and decide - // what happens if it is not added. - css.billingText('Your account has no payment method', testId('card-preview')) - ); - } + 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); } - // Builds the list of summary items indicating why the user is being prompted with - // the payment method page and what will happen when the card information is submitted. + // Summary panel for payment subpage. private _buildPaymentSummary(task: BillingTask) { - if (task === 'signUp' || task === 'updatePlan') { - return dom.maybe(this._model.signupPlan, _plan => this._buildPlanPaymentSummary(_plan, task)); - } else if (task === 'signUpLite') { - return this.buildSubscriptionSummary(); - } else if (task === 'addCard' || task === 'updateCard') { - return makeSummaryFeature('You are updating the default payment method'); - } else if (task === 'updateAddress') { - return makeSummaryFeature('You are updating the company name and address'); + if (task === 'signUpLite') { + return this._buildSubscriptionSummary(); } else if (task === 'updateDomain') { - return makeSummaryFeature('You are updating the company name and domain'); - } else { - return null; - } - } - - private _buildPlanPaymentSummary(plan: IBillingPlan, task: BillingTask) { - return dom.domComputed(this._model.subscription, sub => { - let stubSub: ISubscriptionModel|undefined; - if (sub && !sub.periodEnd) { - // Stripe subscriptions have a defined end. - // If the periodEnd is unknown, that means there is as yet no stripe subscription, - // and the user is signing up or renewing an expired subscription as opposed to upgrading. - stubSub = sub; - sub = undefined; - } - if (plan.amount === 0) { - // User is cancelling their subscription. + 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 if (sub && sub.activePlan && plan.amount < sub.activePlan.amount) { - // User is downgrading their plan. - return [ - makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']), - makeSummaryFeature(['Your plan will change on ', dateFmt(sub.periodEnd)]), - makeSummaryFeature('You will not be charged until the plan changes'), - makeSummaryFeature([`Your new ${plan.interval}ly subtotal is `, - getPriceString(plan.amount * sub.userCount)]) - ]; - } else if (!sub) { - const planPriceStr = getPriceString(plan.amount); - const subtotal = plan.amount * (stubSub?.userCount || 1); - // This is a new subscription, either a fresh sign ups, or renewal after cancellation. - // The server will allow the trial period only for fresh sign ups. - const trialSummary = (plan.trial_period_days && task === 'signUp') ? - makeSummaryFeature([`The plan is free for `, `${plan.trial_period_days} days`]) : null; - return [ - makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']), - dom.domComputed(this._showConfirmPage, confirmPage => { - const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined}; - const subTotalPriceStr = getPriceString(subtotal, subTotalOptions); - const totalPriceStr = getPriceString(subtotal, {...subTotalOptions, taxRate: stubSub?.taxRate ?? 0}); - if (confirmPage) { - return [ - this._formData.coupon ? - makeSummaryFeature([ - 'You applied the ', - this._formData.coupon.name + ` (${getDiscountAmountString(this._formData.coupon)}) `, - ]) : null, - makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]), - // Note that on sign up, the number of users in the new org is always one. - trialSummary || makeSummaryFeature(['You will be charged ', totalPriceStr, ' to start']) - ]; - } else { - return [ - // Note that on sign up, the number of users in the new org is always one. - makeSummaryFeature([`Your price is `, planPriceStr, ` per user per ${plan.interval}`]), - makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]), - trialSummary - ]; - } - }) - ]; - } else if (plan.amount > sub.activePlan.amount) { - const refund = sub.valueRemaining || 0; - const subtotal = plan.amount * sub.userCount; - const taxRate = sub.taxRate; - - // User is upgrading their plan. - return [ - makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']), - dom.domComputed(this._showConfirmPage, confirmPage => { - const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined}; - const subTotalPriceStr = getPriceString(subtotal, subTotalOptions); - const startPriceStr = getPriceString(subtotal, {...subTotalOptions, taxRate, refund}); - - return [ - confirmPage && this._formData.coupon ? - makeSummaryFeature([ - 'You applied the ', - this._formData.coupon.name + ` (${getDiscountAmountString(this._formData.coupon)}) `, - ]) : null, - makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]), - makeSummaryFeature(['You will be charged ', startPriceStr, ' to start']), - refund > 0 ? makeSummaryFeature(['Your charge is prorated based on the remaining plan time']) : null, - ]; - }), - ]; - } else { - // User is cancelling their decision to downgrade their plan. - return [ - makeSummaryFeature(['You will remain subscribed to the ', plan.nickname, ' plan']), - makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, - getPriceString(plan.amount * sub.userCount)]), - makeSummaryFeature(['Your next payment will be on ', dateFmt(sub.periodEnd)]) - ]; - } - }); + }); + } else { + return null; + } } - private _buildPaymentBtns(submitText: string) { + private _buildButtons(submitText: string) { const task = this._model.currentTask.get(); this._isSubmitting.set(false); // Reset status on build. return css.paymentBtnRow( - bigBasicButton('Back', + task !== 'signUpLite' ? bigBasicButton('Back', dom.on('click', () => window.history.back()), - dom.show((use) => task !== 'signUp' || !use(this._showConfirmPage)), + dom.show((use) => !use(this._showConfirmPage)), dom.boolAttr('disabled', this._isSubmitting), testId('back') - ), - bigBasicButtonLink('Edit', + ) : null, + task !== 'cancelPlan' ? bigBasicButtonLink('Edit', dom.show(this._showConfirmPage), dom.on('click', () => this._showConfirmPage.set(false)), dom.boolAttr('disabled', this._isSubmitting), testId('edit') - ), - bigPrimaryButton({style: 'margin-left: 10px;'}, - dom.text((use) => (task !== 'signUp' || use(this._showConfirmPage)) ? submitText : 'Review'), + ) : null, + bigPrimaryButton({ style: 'margin-left: 10px;' }, + dom.text(submitText), dom.boolAttr('disabled', this._isSubmitting), dom.on('click', () => this._doSubmit(task)), testId('submit') @@ -787,26 +465,29 @@ export class BillingPage extends Disposable { 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. In the case of signup, get the tax rate - // and show confirmation data before submitting. - if (task !== 'signUp' || this._showConfirmPage.get()) { + // 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 { - if (this._model.signupTaxRate === undefined) { - await this._model.fetchSignupTaxRate(this._formData); - } this._showConfirmPage.set(true); this._isSubmitting.set(false); } } catch (err) { - // Note that submitPaymentPage/fetchSignupTaxRate are responsible for reporting errors. + // 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); @@ -816,17 +497,9 @@ export class BillingPage extends Disposable { } } } - - private _showRemoveCardModal(): void { - confirmModal(`Remove Payment Card`, 'Remove', - () => this._model.removeCard(), - `This is the only payment method associated with the account.\n\n` + - `If removed, another payment method will need to be added before the ` + - `next payment is due.`); - } } -const statusText: {[key: string]: string} = { +const statusText: { [key: string]: string } = { incomplete: 'incomplete', incomplete_expired: 'incomplete', past_due: 'past due', @@ -842,7 +515,7 @@ function getSubscriptionProblem(sub: ISubscriptionModel) { 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})); + return result.map(msg => makeSummaryFeature(msg, { isBad: true })); } interface PriceOptions { @@ -858,7 +531,7 @@ const defaultPriceOptions: PriceOptions = { }; function getPriceString(priceCents: number, options = defaultPriceOptions): string { - const {taxRate = 0, coupon, refund} = options; + const { taxRate = 0, coupon, refund } = options; if (coupon) { if (coupon.amount_off) { priceCents -= coupon.amount_off; @@ -882,12 +555,19 @@ function getPriceString(priceCents: number, options = defaultPriceOptions): stri }); } -function getDiscountAmountString(coupon: IBillingCoupon): string { - if (coupon.amount_off !== null) { - return `${getPriceString(coupon.amount_off)} off`; - } else { - return `${coupon.percent_off!}% off`; - } +// 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') + ) + )); } /** @@ -901,7 +581,7 @@ function getDiscountAmountString(coupon: IBillingCoupon): string { * If isBad is set, a cross is used instead of a tick */ function makeSummaryFeature( - text: string|string[], + text: string | string[], options: { isMissingFeature?: boolean, isBad?: boolean, attr?: IAttrObj } = {} ) { const textArray = Array.isArray(text) ? text : [text]; @@ -919,28 +599,49 @@ function makeSummaryFeature( } } +function makeActionLink(text: string, icon: IconName, url: DomArg, ...args: DomArg[]) { + 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[]) { + 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}); }) + return dom('a', { href: '#' }, text, + dom.on('click', (ev) => { ev.preventDefault(); beaconOpenMessage({ appModel }); }) ); } -function dateFmt(timestamp: number|null): string { +function dateFmt(timestamp: number | null): string { if (!timestamp) { return "unknown"; } - return new Date(timestamp).toLocaleDateString('default', {month: 'long', day: 'numeric'}); + 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 { +function dateFmtFull(timestamp: number | null): string { if (!timestamp) { return "unknown"; } - return new Date(timestamp).toLocaleDateString('default', {month: 'short', day: 'numeric', year: 'numeric'}); + 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'}); -} - -function formatCityStateZip(address: Partial) { - const cityState = [address.city, address.state].filter(Boolean).join(', '); - return [cityState, address.postal_code].filter(Boolean).join(' '); + { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }); } diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index df94931e..fb774f56 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -10,12 +10,13 @@ import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'ap import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo'; import * as css from 'app/client/ui/DocMenuCss'; import {buildHomeIntro} from 'app/client/ui/HomeIntro'; +import {buildUpgradeNudge} from 'app/client/ui/ProductUpgrades'; import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs'; import {shadowScroll} from 'app/client/ui/shadowScroll'; import {transition} from 'app/client/ui/transitions'; import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions'; import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; -import {colors} from 'app/client/ui2018/cssVars'; +import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus'; @@ -24,11 +25,12 @@ import {IHomePage} from 'app/common/gristUrls'; import {SortPref, ViewPref} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; import {Document, Workspace} from 'app/common/UserAPI'; -import {Computed, computed, dom, DomContents, makeTestId, Observable, observable} from 'grainjs'; +import {Computed, computed, dom, DomContents, makeTestId, Observable, observable, styled} from 'grainjs'; import sortBy = require('lodash/sortBy'); import {buildTemplateDocs} from 'app/client/ui/TemplateDocs'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {bigBasicButton} from 'app/client/ui2018/buttons'; +import {getUserOrgPrefObs, getUserOrgPrefsObs} from 'app/client/models/UserPrefs'; const testId = makeTestId('test-dm-'); @@ -46,94 +48,113 @@ export function createDocMenu(home: HomeModel) { )); } +function createUpgradeNudge(home: HomeModel) { + const isLoggedIn = !!home.app.currentValidUser; + const isOnFreePersonal = home.app.currentOrg?.billingAccount?.product?.name === 'starter'; + const userOrgPrefs = getUserOrgPrefsObs(home.app); + const seenNudge = getUserOrgPrefObs(userOrgPrefs, 'seenFreeTeamUpgradeNudge'); + return dom.maybe(use => isLoggedIn && isOnFreePersonal && !use(seenNudge), + () => buildUpgradeNudge({ + onClose: () => seenNudge.set(true), + // On show prices, we will clear the nudge in database once there is some free team site created + // The better way is to read all workspaces that this person have and decide then - but this is done + // asynchronously - so we potentially can show this nudge to people that already have team site. + onUpgrade: () => home.app.showUpgradeModal() + })); +} + function createLoadedDocMenu(home: HomeModel) { const flashDocId = observable(null); return css.docList( showWelcomeQuestions(home.app.userPrefsObs), - dom.maybe(!home.app.currentFeatures.workspaces, () => [ - css.docListHeader('This service is not available right now'), - dom('span', '(The organization needs a paid plan)') - ]), - - // currentWS and showIntro observables change together. We capture both in one domComputed call. - dom.domComputed<[IHomePage, Workspace|undefined, boolean]>( - (use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)], - ([page, workspace, showIntro]) => { - const viewSettings: ViewSettings = - page === 'trash' ? makeLocalViewSettings(home, 'trash') : - page === 'templates' ? makeLocalViewSettings(home, 'templates') : - workspace ? makeLocalViewSettings(home, workspace.id) : - home; - - return [ - // Hide the sort option only when showing intro. - ((showIntro && page === 'all') ? null : - buildPrefs(viewSettings, {hideSort: showIntro}) - ), + cssDocMenu( + dom.maybe(!home.app.currentFeatures.workspaces, () => [ + css.docListHeader('This service is not available right now'), + dom('span', '(The organization needs a paid plan)') + ]), - // Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or - dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [ - css.docListHeader(css.docHeaderIconDark('PinBig'), 'Pinned Documents'), - createPinnedDocs(home, home.currentWSPinnedDocs), - ]), - - // Build the featured templates dom if on the Examples & Templates page. - dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [ - css.featuredTemplatesHeader( - css.featuredTemplatesIcon('Idea'), - 'Featured', - testId('featured-templates-header') - ), - createPinnedDocs(home, home.featuredTemplates, true), - ]), - - dom.maybe(home.available, () => [ - buildOtherSites(home), - (showIntro && page === 'all' ? - null : - css.docListHeader( - ( - page === 'all' ? 'All Documents' : - page === 'templates' ? - dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => - hasFeaturedTemplates ? 'More Examples & Templates' : 'Examples & Templates' - ) : - page === 'trash' ? 'Trash' : - workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] - ), - testId('doc-header'), - ) + // currentWS and showIntro observables change together. We capture both in one domComputed call. + dom.domComputed<[IHomePage, Workspace|undefined, boolean]>( + (use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)], + ([page, workspace, showIntro]) => { + const viewSettings: ViewSettings = + page === 'trash' ? makeLocalViewSettings(home, 'trash') : + page === 'templates' ? makeLocalViewSettings(home, 'templates') : + workspace ? makeLocalViewSettings(home, workspace.id) : + home; + + return [ + // Hide the sort option only when showing intro. + ((showIntro && page === 'all') ? null : + buildPrefs(viewSettings, {hideSort: showIntro}) ), - ( - (page === 'all') ? - dom('div', - showIntro ? buildHomeIntro(home) : null, - buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings), - shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null, - ) : - (page === 'trash') ? - dom('div', - css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'), - dom.maybe((use) => use(home.trashWorkspaces).length === 0, () => - css.docBlock('Trash is empty.') + + // Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or + dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [ + css.docListHeader(css.docHeaderIconDark('PinBig'), 'Pinned Documents'), + createPinnedDocs(home, home.currentWSPinnedDocs), + ]), + + // Build the featured templates dom if on the Examples & Templates page. + dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [ + css.featuredTemplatesHeader( + css.featuredTemplatesIcon('Idea'), + 'Featured', + testId('featured-templates-header') + ), + createPinnedDocs(home, home.featuredTemplates, true), + ]), + + dom.maybe(home.available, () => [ + buildOtherSites(home), + (showIntro && page === 'all' ? + null : + css.docListHeader( + ( + page === 'all' ? 'All Documents' : + page === 'templates' ? + dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => + hasFeaturedTemplates ? 'More Examples & Templates' : 'Examples & Templates' + ) : + page === 'trash' ? 'Trash' : + workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] ), - buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings), - ) : - (page === 'templates') ? - dom('div', - buildAllTemplates(home, home.templateWorkspaces, viewSettings) - ) : - workspace && !workspace.isSupportWorkspace ? - css.docBlock( - buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings), - testId('doc-block') + testId('doc-header'), + ) + ), + ( + (page === 'all') ? + dom('div', + showIntro ? buildHomeIntro(home) : null, + buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings), + dom.maybe(use => use(isNarrowScreenObs()), () => createUpgradeNudge(home)), + shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null, ) : - css.docBlock('Workspace not found') - ) - ]), - ]; - }), - testId('doclist') + (page === 'trash') ? + dom('div', + css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'), + dom.maybe((use) => use(home.trashWorkspaces).length === 0, () => + css.docBlock('Trash is empty.') + ), + buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings), + ) : + (page === 'templates') ? + dom('div', + buildAllTemplates(home, home.templateWorkspaces, viewSettings) + ) : + workspace && !workspace.isSupportWorkspace ? + css.docBlock( + buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings), + testId('doc-block') + ) : + css.docBlock('Workspace not found') + ) + ]), + ]; + }), + testId('doclist') + ), + dom.maybe(use => !use(isNarrowScreenObs()) && use(home.currentPage) === 'all', () => createUpgradeNudge(home)), ); } @@ -407,7 +428,7 @@ async function doRename(home: HomeModel, doc: Document, val: string, flashDocId: flashDocId.set(doc.id); flashDocId.set(null); } catch (err) { - reportError(err); + reportError(err as Error); } } } @@ -565,3 +586,7 @@ function shouldShowTemplates(home: HomeModel, showIntro: boolean): boolean { // Show templates for all personal orgs, and for non-personal orgs when showing intro. return isPersonalOrg || showIntro; } + +const cssDocMenu = styled('div', ` + flex-grow: 1; +`); diff --git a/app/client/ui/DocMenuCss.ts b/app/client/ui/DocMenuCss.ts index 5f4436ba..fe349422 100644 --- a/app/client/ui/DocMenuCss.ts +++ b/app/client/ui/DocMenuCss.ts @@ -14,6 +14,7 @@ export const docList = styled('div', ` padding: 32px 64px 24px 64px; overflow-y: auto; position: relative; + display: flex; &:after { content: ""; diff --git a/app/client/ui/SiteSwitcher.ts b/app/client/ui/SiteSwitcher.ts index 40494619..26e94f57 100644 --- a/app/client/ui/SiteSwitcher.ts +++ b/app/client/ui/SiteSwitcher.ts @@ -1,9 +1,9 @@ -import {commonUrls, getSingleOrg, shouldHideUiElement} from 'app/common/gristUrls'; +import {getSingleOrg, shouldHideUiElement} from 'app/common/gristUrls'; import {getOrgName} from 'app/common/UserAPI'; import {dom, makeTestId, styled} from 'grainjs'; import {AppModel} from 'app/client/models/AppModel'; import {urlState} from 'app/client/models/gristUrlState'; -import {menuDivider, menuIcon, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; +import {menuDivider, menuIcon, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {icon} from 'app/client/ui2018/icons'; import {colors} from 'app/client/ui2018/cssVars'; @@ -39,8 +39,8 @@ export function buildSiteSwitcher(appModel: AppModel) { testId('org'), ) ), - menuItemLink( - { href: commonUrls.createTeamSite }, + menuItem( + () => appModel.showNewSiteModal(), menuIcon('Plus'), 'Create new team site', testId('create-new-site'), diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 6e6797ca..12957be1 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -100,6 +100,7 @@ export type IconName = "ChartArea" | "Settings" | "Share" | "Sort" | + "Sparks" | "Tick" | "TickSolid" | "Undo" | @@ -222,6 +223,7 @@ export const IconList: IconName[] = ["ChartArea", "Settings", "Share", "Sort", + "Sparks", "Tick", "TickSolid", "Undo", diff --git a/app/common/BillingAPI.ts b/app/common/BillingAPI.ts index efbc745e..3a7900ae 100644 --- a/app/common/BillingAPI.ts +++ b/app/common/BillingAPI.ts @@ -4,14 +4,18 @@ import {StringUnion} from 'app/common/StringUnion'; import {addCurrentOrgToPath} from 'app/common/urlUtils'; import {BillingAccount, ManagerDelta, OrganizationWithoutAccessInfo} from 'app/common/UserAPI'; -export const BillingSubPage = StringUnion('payment', 'plans'); +export const BillingSubPage = StringUnion('payment'); export type BillingSubPage = typeof BillingSubPage.type; export const BillingPage = StringUnion(...BillingSubPage.values, 'billing'); export type BillingPage = typeof BillingPage.type; -export const BillingTask = StringUnion('signUp', 'signUpLite', 'updatePlan', 'addCard', - 'updateCard', 'updateAddress', 'updateDomain'); +// updateDomain - it is a subpage for billing page, to update domain name. +// The rest are for payment page: +// signUpLite - it is a subpage for payment, to finalize (complete) signup process +// and set domain and team name when they are not set yet (currently only from landing pages). +// signUp - it is landing page for new team sites (it doesn't ask for the name of the team) +export const BillingTask = StringUnion('signUpLite', 'updateDomain', 'signUp', 'cancelPlan'); export type BillingTask = typeof BillingTask.type; // Note that IBillingPlan includes selected fields from the Stripe plan object along with @@ -36,24 +40,11 @@ export interface IBillingPlan { }; trial_period_days: number|null; // Number of days in the trial period, or null if there is none. product: string; // the Stripe product id. -} - -// Stripe customer address information. Used to maintain the company address. -// For reference: https://stripe.com/docs/api/customers/object#customer_object-address -export interface IBillingAddress { - line1: string|null; - line2: string|null; - city: string|null; - state: string|null; - postal_code: string|null; - country: string|null; + active: boolean; } // Utility type that requires all properties to be non-nullish. -type NonNullableProperties = { [P in keyof T]: Required>; }; - -// Filled address info from the client. Fields can be blank strings. -export type IFilledBillingAddress = NonNullableProperties; +// type NonNullableProperties = { [P in keyof T]: Required>; }; // Stripe promotion code and coupon information. Used by client to apply signup discounts. // For reference: https://stripe.com/docs/api/promotion_codes/object#promotion_code_object-coupon @@ -74,13 +65,6 @@ export interface IBillingDiscount { end_timestamp_ms: number|null; } -export interface IBillingCard { - funding?: string|null; - brand?: string|null; - country?: string|null; // uppercase two-letter ISO country code - last4?: string|null; // last 4 digits of the card number - name?: string|null; -} export interface IBillingSubscription { // All standard plan options. @@ -98,10 +82,6 @@ export interface IBillingSubscription { // Value in cents remaining for the current subscription. This indicates the amount that // will be discounted from a subscription upgrade. valueRemaining: number; - // The payment card, or null if none is attached. - card: IBillingCard|null; - // The company address. - address: IBillingAddress|null; // The effective tax rate of the customer for the given address. taxRate: number; // The current number of users with whom the paid org is shared. @@ -112,8 +92,18 @@ export interface IBillingSubscription { discount: IBillingDiscount|null; // Last plan we had a subscription for, if any. lastPlanId: string|null; - // Whether there is a valid plan in effect + // Whether there is a valid plan in effect. isValidPlan: boolean; + // A flag for when all is well with the user's subscription. + inGoodStanding: boolean; + // Whether there is a paying valid account (even on free plan). It this is set + // user needs to upgrade the plan using Stripe Customer portal. In not, we need to + // go though checkout process. + activeSubscription: boolean; + // Whether the plan is billable. Billable plans must be in Stripe. + billable: boolean; + // Whether we are waiting for upgrade to complete. + upgradingPlanIndex: number; // Stripe status, documented at https://stripe.com/docs/api/subscriptions/object#subscription_object-status // such as "active", "trialing" (reflected in isInTrial), "incomplete", etc. @@ -136,24 +126,18 @@ export interface FullBillingAccount extends BillingAccount { export interface BillingAPI { isDomainAvailable(domain: string): Promise; - getCoupon(promotionCode: string): Promise; - getTaxRate(address: IBillingAddress): Promise; getPlans(): Promise; getSubscription(): Promise; getBillingAccount(): Promise; - // The signUp function takes the tokenId generated when card data is submitted to Stripe. - // See: https://stripe.com/docs/stripe-js/reference#stripe-create-token - signUp(planId: string, tokenId: string, address: IBillingAddress, - settings: IBillingOrgSettings, promotionCode?: string): Promise; - setCard(tokenId: string): Promise; - removeCard(): Promise; - setSubscription(planId: string, options: { - tokenId?: string, - address?: IBillingAddress, - settings?: IBillingOrgSettings, - }): Promise; - updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise; updateBillingManagers(delta: ManagerDelta): Promise; + updateSettings(settings: IBillingOrgSettings): Promise; + subscriptionStatus(planId: string): Promise; + createFreeTeam(name: string, domain: string): Promise; + createTeam(name: string, domain: string): Promise; + upgrade(): Promise; + cancelCurrentPlan(): Promise; + renewPlan(): string; + customerPortal(): string; } export class BillingAPIImpl extends BaseAPI implements BillingAPI { @@ -167,20 +151,6 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI { body: JSON.stringify({ domain }) }); } - - public async getCoupon(promotionCode: string): Promise { - return this.requestJson(`${this._url}/api/billing/coupon/${promotionCode}`, { - method: 'GET', - }); - } - - public async getTaxRate(address: IBillingAddress): Promise { - return this.requestJson(`${this._url}/api/billing/tax`, { - method: 'POST', - body: JSON.stringify({ address }) - }); - } - public async getPlans(): Promise { return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'}); } @@ -194,58 +164,74 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI { return this.requestJson(`${this._url}/api/billing`, {method: 'GET'}); } - // Returns the new Stripe customerId. - public async signUp( - planId: string, - tokenId: string, - address: IBillingAddress, - settings: IBillingOrgSettings, - promotionCode?: string, - ): Promise { - const parsed = await this.requestJson(`${this._url}/api/billing/signup`, { + public async cancelCurrentPlan() { + await this.request(`${this._url}/api/billing/cancel-plan`, { method: 'POST', - body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }), }); - return parsed.data; } - public async setSubscription(planId: string, options: { - tokenId?: string, - address?: IBillingAddress, - }): Promise { - await this.request(`${this._url}/api/billing/subscription`, { + + public async updateSettings(settings?: IBillingOrgSettings): Promise { + await this.request(`${this._url}/api/billing/settings`, { method: 'POST', - body: JSON.stringify({ ...options, planId }) + body: JSON.stringify({ settings }) }); } - public async removeSubscription(): Promise { - await this.request(`${this._url}/api/billing/subscription`, {method: 'DELETE'}); + public async updateBillingManagers(delta: ManagerDelta): Promise { + await this.request(`${this._url}/api/billing/managers`, { + method: 'PATCH', + body: JSON.stringify({delta}) + }); } - public async setCard(tokenId: string): Promise { - await this.request(`${this._url}/api/billing/card`, { + public async createFreeTeam(name: string, domain: string): Promise { + const data = await this.requestJson(`${this._url}/api/billing/team-free`, { method: 'POST', - body: JSON.stringify({ tokenId }) + body: JSON.stringify({ + domain, + name + }) }); + return data.orgUrl; } - public async removeCard(): Promise { - await this.request(`${this._url}/api/billing/card`, {method: 'DELETE'}); + public async createTeam(name: string, domain: string): Promise { + const data = await this.requestJson(`${this._url}/api/billing/team`, { + method: 'POST', + body: JSON.stringify({ + domain, + name, + planType: 'team' + }) + }); + return data.checkoutUrl; } - public async updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise { - await this.request(`${this._url}/api/billing/address`, { + public async upgrade(): Promise { + const data = await this.requestJson(`${this._url}/api/billing/upgrade`, { method: 'POST', - body: JSON.stringify({ address, settings }) }); + return data.checkoutUrl; } - public async updateBillingManagers(delta: ManagerDelta): Promise { - await this.request(`${this._url}/api/billing/managers`, { - method: 'PATCH', - body: JSON.stringify({delta}) + public customerPortal(): string { + return `${this._url}/api/billing/customer-portal`; + } + + public renewPlan(): string { + return `${this._url}/api/billing/renew`; + } + + /** + * Checks if current org has active subscription for a Stripe plan. + */ + public async subscriptionStatus(planId: string): Promise { + const data = await this.requestJson(`${this._url}/api/billing/status`, { + method: 'POST', + body: JSON.stringify({planId}) }); + return data.active; } private get _url(): string { diff --git a/app/common/Features.ts b/app/common/Features.ts index f22bf75f..0f18d146 100644 --- a/app/common/Features.ts +++ b/app/common/Features.ts @@ -9,6 +9,7 @@ export interface Product { features: Features; } + // A product is essentially a list of flags and limits that we may enforce/support. export interface Features { vanityDomain?: boolean; // are user-selected domains allowed (unenforced) (default: true) @@ -69,5 +70,5 @@ export function canAddOrgMembers(features: Features): boolean { // Returns true if `product` is free. export function isFreeProduct(product: Product): boolean { - return ['starter', 'teamFree'].includes(product.name); + return ['starter', 'teamFree', 'Free'].includes(product?.name); } diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index c1d6ee8b..2bb75e82 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -38,6 +38,9 @@ export interface UserOrgPrefs extends Prefs { // List of document IDs where the user has seen and dismissed the document tour. seenDocTours?: string[]; + + // Whether the user seen the nudge to upgrade to Free Team Site and dismissed it. + seenFreeTeamUpgradeNudge?: boolean; } export type OrgPrefs = Prefs; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 3ceed634..3a9930da 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -68,6 +68,7 @@ export interface BillingAccount { individual: boolean; product: Product; isManager: boolean; + inGoodStanding: boolean; externalOptions?: { invoiceId?: string; }; diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index e028561a..04a089c9 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -73,7 +73,7 @@ export function addOrg( userId: number, props: Partial, options?: { - planType?: 'free' + planType?: string, } ): Promise { return dbManager.connection.transaction(async manager => { diff --git a/app/gen-server/entity/Product.ts b/app/gen-server/entity/Product.ts index fcafa07a..8cb14876 100644 --- a/app/gen-server/entity/Product.ts +++ b/app/gen-server/entity/Product.ts @@ -98,6 +98,7 @@ export const PRODUCTS: IProduct[] = [ }, // These are products set up in stripe. + // TODO: this is not true anymore { name: 'starter', features: starterFeatures, @@ -108,21 +109,22 @@ export const PRODUCTS: IProduct[] = [ }, { name: 'team', - features: teamFeatures, + features: teamFeatures }, // This is a product for a team site that is no longer in good standing, but isn't yet // to be removed / deactivated entirely. { name: 'suspended', - features: suspendedFeatures, + features: suspendedFeatures }, { name: 'teamFree', - features: teamFreeFeatures, + features: teamFreeFeatures }, ]; + /** * Get names of products for different situations. */ diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index dfe43f2b..83ca3bfd 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -823,6 +823,7 @@ export class HomeDBManager extends EventEmitter { features: starterFeatures, }, isManager: false, + inGoodStanding: true, }, host: null }; @@ -1263,6 +1264,7 @@ export class HomeDBManager extends EventEmitter { * @param useNewPlan: by default, the individual billing account associated with the * user's personal org will be used for all other orgs they create. Set useNewPlan * to force a distinct non-individual billing account to be used for this org. + * NOTE: Currently it is always a true - billing account is one to one with org. * @param planType: if set, controls the type of plan used for the org. Only * meaningful for team sites currently. * @@ -1270,7 +1272,7 @@ export class HomeDBManager extends EventEmitter { public async addOrg(user: User, props: Partial, options: { setUserAsOwner: boolean, useNewPlan: boolean, - planType?: 'free', + planType?: string, externalId?: string, externalOptions?: ExternalBillingOptions }, transaction?: EntityManager): Promise> { @@ -1297,13 +1299,15 @@ export class HomeDBManager extends EventEmitter { // Create or find a billing account to associate with this org. const billingAccountEntities = []; let billingAccount; - if (options.useNewPlan) { + if (options.useNewPlan) { // use separate billing account (currently yes) const productNames = getDefaultProductNames(); let productName = options.setUserAsOwner ? productNames.personal : - options.planType === 'free' ? productNames.teamFree : productNames.teamInitial; + options.planType === productNames.teamFree ? productNames.teamFree : productNames.teamInitial; // A bit fragile: this is called during creation of support@ user, before // getSupportUserId() is available, but with setUserAsOwner of true. - if (!options.setUserAsOwner && user.id === this.getSupportUserId() && options.planType !== 'free') { + if (!options.setUserAsOwner + && user.id === this.getSupportUserId() + && options.planType !== productNames.teamFree) { // For teams created by support@getgrist.com, set the product to something // good so payment not needed. This is useful for testing. productName = productNames.team; @@ -1328,6 +1332,7 @@ export class HomeDBManager extends EventEmitter { billingAccount.externalOptions = options.externalOptions; } } else { + log.warn("Creating org with shared billing account"); // Use the billing account from the user's personal org to start with. billingAccount = await manager.createQueryBuilder() .select('billing_accounts') diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 42e7d52a..9f80b9e7 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1,4 +1,3 @@ -import {BillingTask} from 'app/common/BillingAPI'; import {delay} from 'app/common/delay'; import {DocCreationInfo} from 'app/common/DocListAPI'; import {encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, isOrgInPathOnly, @@ -7,7 +6,7 @@ import {getOrgUrlInfo} from 'app/common/gristUrls'; import {UserProfile} from 'app/common/LoginSessionAPI'; import {tbind} from 'app/common/tbind'; import * as version from 'app/common/version'; -import {ApiServer} from 'app/gen-server/ApiServer'; +import {ApiServer, getOrgFromRequest} 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'; @@ -70,6 +69,7 @@ import {AddressInfo} from 'net'; import fetch from 'node-fetch'; import * as path from 'path'; import * as serveStatic from "serve-static"; +import {BillingTask} from 'app/common/BillingAPI'; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: @@ -565,7 +565,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._billing.addEndpoints(this.app, this); this._billing.addEventHandlers(); } @@ -685,7 +685,7 @@ export class FlexServer implements GristServer { await axios.get(statusUrl); return w.data; } catch (err) { - log.debug(`While waiting for ${statusUrl} got error ${err.message}`); + log.debug(`While waiting for ${statusUrl} got error ${(err as Error).message}`); } } throw new Error(`Cannot connect to ${statusUrl}`); @@ -759,6 +759,8 @@ export class FlexServer implements GristServer { } if (mreq.org && mreq.org.startsWith('o-')) { // We are on a team site without a custom subdomain. + const orgInfo = this._dbManager.unwrapQueryResult(await this._dbManager.getOrg({userId: user.id}, mreq.org)); + // If the user is a billing manager for the org, and the org // is supposed to have a custom subdomain, forward the user // to a page to set it. @@ -769,10 +771,9 @@ export class FlexServer implements GristServer { // If "welcomeNewUser" is ever added to billing pages, we'd need // to avoid a redirect loop. - const orgInfo = this._dbManager.unwrapQueryResult(await this._dbManager.getOrg({userId: user.id}, mreq.org)); if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) { - const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; - return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); + const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; + return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); } } next(); @@ -1091,6 +1092,16 @@ 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; @@ -1109,20 +1120,31 @@ export class FlexServer implements GristServer { })); this.app.get('/billing/payment', ...middleware, expressWrap(async (req, resp, next) => { - const task = optStringParam(req.query.billingTask) || ''; - const planRequired = task === 'signup' || task === 'updatePlan'; - if (!BillingTask.guard(task) || (planRequired && !req.query.billingPlan)) { - // If the payment task/plan are invalid, redirect to the summary page. + 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: {}}); } })); - // This endpoint is used only during testing, to support existing tests that - // depend on a page that has been removed. - this.app.get('/test/support/billing/plans', expressWrap(async (req, resp, next) => { - return this._sendAppPage(req, resp, {path: 'billing.html', status: 200, config: {}}); + /** + * Add landing page for creating pro team sites. Creates new org and redirect to Stripe Checkout Page. + * @param billingPlan Stripe plan/price id to use. Must be a standard plan that resolves to a billable product. + * @param planType Product type to use. Grist will look for a Stripe Product with a default price + * that has metadata 'gristProduct' parameter with this plan. If billingPlan is passed, this + * parameter is ignored. + */ + this.app.get('/billing/signup', ...middleware, expressWrap(async (req, resp, next) => { + const planType = optStringParam(req.query.planType) || ''; + const billingPlan = optStringParam(req.query.billingPlan) || ''; + if (!planType && !billingPlan) { + return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}}); + } + // Redirect to GET endpoint in the billing api to create a team site. + const url = `${getPrefix(req)}/api/billing/signup?planType=${planType}&billingPlan=${billingPlan}`; + return resp.redirect(url); })); } @@ -1242,12 +1264,17 @@ export class FlexServer implements GristServer { * Get a url for a team site. */ public async getOrgUrl(orgKey: string|number): Promise { + const org = await this.getOrg(orgKey); + return this.getResourceUrl(org); + } + + public async getOrg(orgKey: string|number) { if (!this._dbManager) { throw new Error('database missing'); } const org = await this._dbManager.getOrg({ userId: this._dbManager.getPreviewerUserId(), showAll: true }, orgKey); - return this.getResourceUrl(this._dbManager.unwrapQueryResult(org)); + return this._dbManager.unwrapQueryResult(org); } /** diff --git a/app/server/lib/IBilling.ts b/app/server/lib/IBilling.ts index 0cf66519..4f73fe14 100644 --- a/app/server/lib/IBilling.ts +++ b/app/server/lib/IBilling.ts @@ -1,7 +1,8 @@ import * as express from 'express'; +import {GristServer} from 'app/server/lib/GristServer'; export interface IBilling { - addEndpoints(app: express.Express): void; + addEndpoints(app: express.Express, server: GristServer): void; addEventHandlers(): void; addWebhooks(app: express.Express): void; addMiddleware?(app: express.Express): Promise; diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index d99957ff..f204e784 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -181,7 +181,7 @@ export async function sendReply( result: QueryResult, options: SendReplyOptions = {}, ) { - const data = pruneAPIResult(result.data || null, options.allowedFields); + const data = pruneAPIResult(result.data, options.allowedFields); if (shouldLogApiDetails && req) { const mreq = req as RequestWithLogin; log.rawDebug('api call', { @@ -196,7 +196,7 @@ export async function sendReply( }); } if (result.status === 200) { - return res.json(data); + return res.json(data ?? null); // can't handle undefined } else { return res.status(result.status).json({error: result.errMessage}); } @@ -228,7 +228,7 @@ export function pruneAPIResult(data: T, allowedFields?: Set): T { if (key === 'connectId' && value === null) { return undefined; } return INTERNAL_FIELDS.has(key) ? undefined : value; }); - return JSON.parse(output); + return output !== undefined ? JSON.parse(output) : undefined; } /** diff --git a/static/icons/icons.css b/static/icons/icons.css index f3dae919..9c10950c 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -101,6 +101,7 @@ --icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Share: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNzc0MTEwMDUsOS4xNDQxMzAyOSBMOS43NTY4Nzg3NiwxMS4wMDc4NjUxIEMxMC4zMDY0MDY1LDEwLjM4OTU1MjMgMTEuMTA3NzEwOSwxMCAxMiwxMCBDMTMuNjU2ODU0MiwxMCAxNSwxMS4zNDMxNDU4IDE1LDEzIEMxNSwxNC42NTY4NTQyIDEzLjY1Njg1NDIsMTYgMTIsMTYgQzEwLjM0MzE0NTgsMTYgOSwxNC42NTY4NTQyIDksMTMgQzksMTIuNTk0NjU3MiA5LjA4MDM4OTUzLDEyLjIwODA5MDQgOS4yMjYwOTUwNywxMS44NTUzNzMgTDYuMjQzNDc4MjQsOS45OTE3MzMxNyBDNS42OTM5NDA1NSwxMC42MTAyNzkgNC44OTI0ODIzOSwxMSA0LDExIEMyLjM0MzE0NTc1LDExIDEsOS42NTY4NTQyNSAxLDggQzEsNi4zNDMxNDU3NSAyLjM0MzE0NTc1LDUgNCw1IEM0Ljg5MjQ4MjM5LDUgNS42OTM5NDA1NSw1LjM4OTcyMTAzIDYuMjQzNDc4MjQsNi4wMDgyNjY4MyBMOS4yMjYwOTUwNyw0LjE0NDYyNjk2IEM5LjA4MDM4OTUzLDMuNzkxOTA5NjMgOSwzLjQwNTM0MjggOSwzIEM5LDEuMzQzMTQ1NzUgMTAuMzQzMTQ1OCwwIDEyLDAgQzEzLjY1Njg1NDIsMCAxNSwxLjM0MzE0NTc1IDE1LDMgQzE1LDQuNjU2ODU0MjUgMTMuNjU2ODU0Miw2IDEyLDYgQzExLjEwNzcxMDksNiAxMC4zMDY0MDY1LDUuNjEwNDQ3NzMgOS43NTY4Nzg3Niw0Ljk5MjEzNDkzIEw2Ljc3NDExMDA1LDYuODU1ODY5NzEgQzYuOTE5Njg1OTIsNy4yMDg0NTMyNSA3LDcuNTk0ODQ3NDQgNyw4IEM3LDguNDA1MTUyNTYgNi45MTk2ODU5Miw4Ljc5MTU0Njc1IDYuNzc0MTEwMDUsOS4xNDQxMzAyOSBaIE01LjcwNDEyNDg1LDkuMDQ3NDE3MTYgQzUuODkxNzYwNjYsOC43NDI3ODczNCA2LDguMzg0MDM0IDYsOCBDNiw3LjYxNTk2NiA1Ljg5MTc2MDY2LDcuMjU3MjEyNjYgNS43MDQxMjQ4NSw2Ljk1MjU4Mjg0IEM1LjcwMTM1MzQ2LDYuOTQ4NDI1NzcgNS42OTg2MzQ0Miw2Ljk0NDIxNDMyIDUuNjk1OTY5MTgsNi45Mzk5NDg4IEM1LjY5MzQ2MjQ3LDYuOTM1OTM2OTkgNS42OTEwMTk1OSw2LjkzMTkwMzM4IDUuNjg4NjQwMjksNi45Mjc4NDkxNSBDNS4zMzM3NDk5MSw2LjM3MDA2MTY1IDQuNzEwMDkxMjIsNiA0LDYgQzIuODk1NDMwNSw2IDIsNi44OTU0MzA1IDIsOCBDMiw5LjEwNDU2OTUgMi44OTU0MzA1LDEwIDQsMTAgQzQuNzEwMDkxMjIsMTAgNS4zMzM3NDk5MSw5LjYyOTkzODM1IDUuNjg4NjQwMjksOS4wNzIxNTA4NSBDNS42OTEwMTk1OSw5LjA2ODA5NjYyIDUuNjkzNDYyNDcsOS4wNjQwNjMwMSA1LjY5NTk2OTE4LDkuMDYwMDUxMiBDNS42OTg2MzQ0Miw5LjA1NTc4NTY4IDUuNzAxMzUzNDYsOS4wNTE1NzQyMyA1LjcwNDEyNDg1LDkuMDQ3NDE3MTYgWiBNMTAuMzE5OTA5MiwxMS45MTQ1MjgzIEMxMC4zMTUyMjY4LDExLjkyMzA4OTcgMTAuMzEwMjY4MSwxMS45MzE1NjY5IDEwLjMwNTAzMDgsMTEuOTM5OTQ4OCBDMTAuMjk5NTk2LDExLjk0ODY0NjggMTAuMjkzOTM3NiwxMS45NTcxMTk5IDEwLjI4ODA2NzYsMTEuOTY1MzY1NSBDMTAuMTA1MjQsMTIuMjY3MjI4NiAxMCwxMi42MjEzMjQzIDEwLDEzIEMxMCwxNC4xMDQ1Njk1IDEwLjg5NTQzMDUsMTUgMTIsMTUgQzEzLjEwNDU2OTUsMTUgMTQsMTQuMTA0NTY5NSAxNCwxMyBDMTQsMTEuODk1NDMwNSAxMy4xMDQ1Njk1LDExIDEyLDExIEMxMS4yOTU1NzY3LDExIDEwLjY3NjIxMTcsMTEuMzY0MTc3NSAxMC4zMTk5MDkyLDExLjkxNDUyODMgWiBNMTAuMjg4MDY3Niw0LjAzNDYzNDU0IEMxMC4yOTM5Mzc2LDQuMDQyODgwMDkgMTAuMjk5NTk2LDQuMDUxMzUzMjUgMTAuMzA1MDMwOCw0LjA2MDA1MTIgQzEwLjMxMDI2ODEsNC4wNjg0MzMxNSAxMC4zMTUyMjY4LDQuMDc2OTEwMjUgMTAuMzE5OTA5Miw0LjA4NTQ3MTY4IEMxMC42NzYyMTE3LDQuNjM1ODIyNDUgMTEuMjk1NTc2Nyw1IDEyLDUgQzEzLjEwNDU2OTUsNSAxNCw0LjEwNDU2OTUgMTQsMyBDMTQsMS44OTU0MzA1IDEzLjEwNDU2OTUsMSAxMiwxIEMxMC44OTU0MzA1LDEgMTAsMS44OTU0MzA1IDEwLDMgQzEwLDMuMzc4Njc1NzQgMTAuMTA1MjQsMy43MzI3NzEzNyAxMC4yODgwNjc2LDQuMDM0NjM0NTQgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Sort: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTk5MjUsMy43MDY1NTM3NyBMMy45OTkyNSwxMy40OTkyNSBDMy45OTkyNSwxMy43NzUyNSAzLjc3NTI1LDEzLjk5OTI1IDMuNDk5MjUsMTMuOTk5MjUgQzMuMjIzMjUsMTMuOTk5MjUgMi45OTkyNSwxMy43NzUyNSAyLjk5OTI1LDEzLjQ5OTI1IEwyLjk5OTI1LDMuNzA2NTUzNzcgTDEuODUzMjUsNC44NTMyNSBDMS42NTgyNSw1LjA0ODI1IDEuMzQxMjUsNS4wNDgyNSAxLjE0NjI1LDQuODUzMjUgQzAuOTUxMjUsNC42NTgyNSAwLjk1MTI1LDQuMzQxMjUgMS4xNDYyNSw0LjE0NjI1IEwzLjE0NjI1LDIuMTQ2MjUgQzMuMzQxMjUsMS45NTEyNSAzLjY1ODI1LDEuOTUxMjUgMy44NTMyNSwyLjE0NjI1IEw1Ljg1MzI1LDQuMTQ2MjUgQzYuMDQ4MjUsNC4zNDEyNSA2LjA0ODI1LDQuNjU4MjUgNS44NTMyNSw0Ljg1MzI1IEM1Ljc1NTI1LDQuOTUwMjUgNS42MjcyNSw0Ljk5OTI1IDUuNDk5MjUsNC45OTkyNSBDNS4zNzEyNSw0Ljk5OTI1IDUuMjQzMjUsNC45NTAyNSA1LjE0NTI1LDQuODUzMjUgTDMuOTk5MjUsMy43MDY1NTM3NyBaIE04LjQ5OTI1LDMuOTk5MjUgTDcuNDk5MjUsMy45OTkyNSBDNy4yMjMyNSwzLjk5OTI1IDYuOTk5MjUsMy43NzUyNSA2Ljk5OTI1LDMuNDk5MjUgQzYuOTk5MjUsMy4yMjMyNSA3LjIyMzI1LDIuOTk5MjUgNy40OTkyNSwyLjk5OTI1IEw4LjQ5OTI1LDIuOTk5MjUgQzguNzc1MjUsMi45OTkyNSA4Ljk5OTI1LDMuMjIzMjUgOC45OTkyNSwzLjQ5OTI1IEM4Ljk5OTI1LDMuNzc1MjUgOC43NzUyNSwzLjk5OTI1IDguNDk5MjUsMy45OTkyNSBaIE0xMC40OTkyNSw2Ljk5OTI1IEw3LjQ5OTI1LDYuOTk5MjUgQzcuMjIzMjUsNi45OTkyNSA2Ljk5OTI1LDYuNzc1MjUgNi45OTkyNSw2LjQ5OTI1IEM2Ljk5OTI1LDYuMjIzMjUgNy4yMjMyNSw1Ljk5OTI1IDcuNDk5MjUsNS45OTkyNSBMMTAuNDk5MjUsNS45OTkyNSBDMTAuNzc1MjUsNS45OTkyNSAxMC45OTkyNSw2LjIyMzI1IDEwLjk5OTI1LDYuNDk5MjUgQzEwLjk5OTI1LDYuNzc1MjUgMTAuNzc1MjUsNi45OTkyNSAxMC40OTkyNSw2Ljk5OTI1IFogTTEyLjQ5OTI1LDkuOTk5MjUgTDcuNDk5MjUsOS45OTkyNSBDNy4yMjMyNSw5Ljk5OTI1IDYuOTk5MjUsOS43NzUyNSA2Ljk5OTI1LDkuNDk5MjUgQzYuOTk5MjUsOS4yMjMyNSA3LjIyMzI1LDguOTk5MjUgNy40OTkyNSw4Ljk5OTI1IEwxMi40OTkyNSw4Ljk5OTI1IEMxMi43NzUyNSw4Ljk5OTI1IDEyLjk5OTI1LDkuMjIzMjUgMTIuOTk5MjUsOS40OTkyNSBDMTIuOTk5MjUsOS43NzUyNSAxMi43NzUyNSw5Ljk5OTI1IDEyLjQ5OTI1LDkuOTk5MjUgWiBNMTQuNDk5MjUsMTIuOTk5MjUgTDcuNDk5MjUsMTIuOTk5MjUgQzcuMjIzMjUsMTIuOTk5MjUgNi45OTkyNSwxMi43NzUyNSA2Ljk5OTI1LDEyLjQ5OTI1IEM2Ljk5OTI1LDEyLjIyMzI1IDcuMjIzMjUsMTEuOTk5MjUgNy40OTkyNSwxMS45OTkyNSBMMTQuNDk5MjUsMTEuOTk5MjUgQzE0Ljc3NTI1LDExLjk5OTI1IDE0Ljk5OTI1LDEyLjIyMzI1IDE0Ljk5OTI1LDEyLjQ5OTI1IEMxNC45OTkyNSwxMi43NzUyNSAxNC43NzUyNSwxMi45OTkyNSAxNC40OTkyNSwxMi45OTkyNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDAgMTYpIi8+PC9zdmc+'); + --icon-Sparks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDYiIGhlaWdodD0iNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTM0LjYyNDggMzAuMjE5QzMzLjU1NzUgMjkuNDYxMiAzMi4yODkzIDI5LjAzNjkgMzAuOTgwOCAyOUMyOS4wNDE4IDI5LjAzNiAyNi4wNzQ4IDI5Ljk5MiAyMy45OTk4IDM0Ljc0N1YxMy45ODdDMjMuOTk5OCAxMy43MjE4IDIzLjg5NDQgMTMuNDY3NCAyMy43MDY5IDEzLjI3OTlDMjMuNTE5NCAxMy4wOTI0IDIzLjI2NSAxMi45ODcgMjIuOTk5OCAxMi45ODdDMjIuNzM0NiAxMi45ODcgMjIuNDgwMiAxMy4wOTI0IDIyLjI5MjcgMTMuMjc5OUMyMi4xMDUyIDEzLjQ2NzQgMjEuOTk5OCAxMy43MjE4IDIxLjk5OTggMTMuOTg3VjM0Ljc0N0MxOS45MjQ4IDI5Ljk5MiAxNi45NTc4IDI5LjAzNiAxNS4wMTg4IDI5SDE0LjkyNDhDMTMuNjQzNyAyOS4wMjkyIDEyLjQwMzIgMjkuNDU1NSAxMS4zNzQ4IDMwLjIyQzExLjI3MjIgMzAuMzAyMSAxMS4xODY4IDMwLjQwMzUgMTEuMTIzNSAzMC41MTg2QzExLjA2MDIgMzAuNjMzNyAxMS4wMjAxIDMwLjc2MDEgMTEuMDA1NiAzMC44OTA3QzEwLjk5MTEgMzEuMDIxMiAxMS4wMDI1IDMxLjE1MzQgMTEuMDM5MSAzMS4yNzk1QzExLjA3NTcgMzEuNDA1NyAxMS4xMzY3IDMxLjUyMzQgMTEuMjE4OCAzMS42MjZDMTEuMzAwOSAzMS43Mjg2IDExLjQwMjQgMzEuODE0IDExLjUxNzQgMzEuODc3M0MxMS42MzI1IDMxLjk0MDYgMTEuNzU4OSAzMS45ODA3IDExLjg4OTUgMzEuOTk1MkMxMi4wMjAxIDMyLjAwOTcgMTIuMTUyMiAzMS45OTgzIDEyLjI3ODQgMzEuOTYxN0MxMi40MDQ1IDMxLjkyNTEgMTIuNTIyMiAzMS44NjQxIDEyLjYyNDggMzEuNzgyQzEzLjI5NjEgMzEuMjk3MyAxNC4wOTcyIDMxLjAyNDkgMTQuOTI0OCAzMUgxNC45ODE4QzE4LjU0ODggMzEuMDY1IDIxLjEwODggMzUuNDgyIDIyLjAwNzggNDMuMTE3QzIyLjAzNjcgNDMuMzYwMSAyMi4xNTM4IDQzLjU4NDEgMjIuMzM2OCA0My43NDY2QzIyLjUxOTggNDMuOTA5MSAyMi43NTYgNDMuOTk4OSAyMy4wMDA4IDQzLjk5ODlDMjMuMjQ1NiA0My45OTg5IDIzLjQ4MTggNDMuOTA5MSAyMy42NjQ4IDQzLjc0NjZDMjMuODQ3OCA0My41ODQxIDIzLjk2NDkgNDMuMzYwMSAyMy45OTM4IDQzLjExN0MyNC44OTM4IDM1LjQ4MiAyNy40NTI4IDMxLjA2NSAzMS4wMTk4IDMxSDMxLjA3NjhDMzEuOTA0MyAzMS4wMjgyIDMyLjcwNDcgMzEuMzAxNCAzMy4zNzY4IDMxLjc4NUMzMy41ODQ3IDMxLjk0NTggMzMuODQ3NiAzMi4wMTgzIDM0LjEwODUgMzEuOTg2OEMzNC4zNjk1IDMxLjk1NTIgMzQuNjA3NSAzMS44MjIyIDM0Ljc3MTIgMzEuNjE2NUMzNC45MzQ4IDMxLjQxMDggMzUuMDEwOSAzMS4xNDkgMzQuOTgyOSAzMC44ODc2QzM0Ljk1NSAzMC42MjYzIDM0LjgyNTIgMzAuMzg2NCAzNC42MjE4IDMwLjIyTDM0LjYyNDggMzAuMjE5WiIgZmlsbD0iIzI2MjYzMyIvPjxwYXRoIGQ9Ik0xMC4yNTMxIDQyQzEwLjA5MSA0MS45OTk5IDkuOTMxNDUgNDEuOTYwNCA5Ljc4ODA2IDQxLjg4NUw3LjAwMDA2IDQwLjQyIDQuMjEyMDYgNDEuODg1QzQuMDQ2OTQgNDEuOTcxNSAzLjg2MDkxIDQyLjAxMDIgMy42NzQ5OCA0MS45OTY2IDMuNDg5MDUgNDEuOTgzMSAzLjMxMDYxIDQxLjkxNzggMy4xNTk4MiA0MS44MDgyIDMuMDA5MDIgNDEuNjk4NiAyLjg5MTg3IDQxLjU0OSAyLjgyMTU4IDQxLjM3NjMgMi43NTEzIDQxLjIwMzYgMi43MzA2OCA0MS4wMTQ3IDIuNzYyMDYgNDAuODMxTDMuMjk0MDYgMzcuNzMxIDEuMDM4MDYgMzUuNTMxQy45MDYwODIgMzUuNDAwNS44MTMwMzUgMzUuMjM1OC43NjkzMyAzNS4wNTU0LjcyNTYyNSAzNC44NzUuNzMyOTg2IDM0LjY4Ni43OTA1ODkgMzQuNTA5Ni44NDgxOTIgMzQuMzMzMi45NTM3NjQgMzQuMTc2MiAxLjA5NTQ5IDM0LjA1NjQgMS4yMzcyMSAzMy45MzY1IDEuNDA5NSAzMy44NTg1IDEuNTkzMDYgMzMuODMxTDQuNzA5MDYgMzMuMzc3IDYuMTA5MDYgMzAuNTUzQzYuMjAwNzQgMzAuMzk2OCA2LjMzMTY2IDMwLjI2NzMgNi40ODg4MyAzMC4xNzczIDYuNjQ2IDMwLjA4NzMgNi44MjM5NSAzMC4wNCA3LjAwNTA2IDMwLjA0IDcuMTg2MTYgMzAuMDQgNy4zNjQxMSAzMC4wODczIDcuNTIxMjggMzAuMTc3MyA3LjY3ODQ1IDMwLjI2NzMgNy44MDkzNyAzMC4zOTY4IDcuOTAxMDYgMzAuNTUzTDkuMjk2MDUgMzMuMzc3IDEyLjQxMjEgMzMuODMxQzEyLjU5NTYgMzMuODU4NSAxMi43Njc5IDMzLjkzNjUgMTIuOTA5NiAzNC4wNTY0IDEzLjA1MTMgMzQuMTc2MiAxMy4xNTY5IDM0LjMzMzIgMTMuMjE0NSAzNC41MDk2IDEzLjI3MjEgMzQuNjg2IDEzLjI3OTUgMzQuODc1IDEzLjIzNTggMzUuMDU1NCAxMy4xOTIxIDM1LjIzNTggMTMuMDk5IDM1LjQwMDUgMTIuOTY3MSAzNS41MzFMMTAuNzExMSAzNy43MzEgMTEuMjQzMSA0MC44MzFDMTEuMjY3NyA0MC45NzQ5IDExLjI2MDYgNDEuMTIyNCAxMS4yMjIyIDQxLjI2MzMgMTEuMTgzNyA0MS40MDQxIDExLjExNDkgNDEuNTM0OCAxMS4wMjA2IDQxLjY0NjMgMTAuOTI2MiA0MS43NTc3IDEwLjgwODYgNDEuODQ3MSAxMC42NzYgNDEuOTA4MiAxMC41NDM0IDQxLjk2OTMgMTAuMzk5MSA0Mi4wMDA2IDEwLjI1MzEgNDJ6TTI2LjI1MzEgMTJDMjYuMDkxIDExLjk5OTkgMjUuOTMxNSAxMS45NjA0IDI1Ljc4ODEgMTEuODg1TDIzLjAwMDEgMTAuNDIgMjAuMjEyMSAxMS44ODVDMjAuMDQ2OSAxMS45NzE1IDE5Ljg2MDkgMTIuMDEwMiAxOS42NzUgMTEuOTk2NiAxOS40ODkxIDExLjk4MzEgMTkuMzEwNiAxMS45MTc4IDE5LjE1OTggMTEuODA4MiAxOS4wMDkgMTEuNjk4NiAxOC44OTE5IDExLjU0OSAxOC44MjE2IDExLjM3NjMgMTguNzUxMyAxMS4yMDM2IDE4LjczMDcgMTEuMDE0OCAxOC43NjIxIDEwLjgzMUwxOS4yOTQxIDcuNzMwOTkgMTcuMDM4MSA1LjUzMDk5QzE2LjkwNjEgNS40MDA0OCAxNi44MTMgNS4yMzU4MSAxNi43NjkzIDUuMDU1NDIgMTYuNzI1NiA0Ljg3NTAzIDE2LjczMyA0LjY4NjA0IDE2Ljc5MDYgNC41MDk2IDE2Ljg0ODIgNC4zMzMxNiAxNi45NTM4IDQuMTc2MjMgMTcuMDk1NSA0LjA1NjM4IDE3LjIzNzIgMy45MzY1MyAxNy40MDk1IDMuODU4NDggMTcuNTkzMSAzLjgzMDk5TDIwLjcwOTEgMy4zNzY5OSAyMi4xMDAxLjU0NDk4NkMyMi4xOTE3LjM4ODgwOSAyMi4zMjI3LjI1OTMxMSAyMi40Nzk4LjE2OTMzIDIyLjYzNy4wNzkzNSAyMi44MTUuMDMyMDEyOSAyMi45OTYxLjAzMjAxMjkgMjMuMTc3Mi4wMzIwMTI5IDIzLjM1NTEuMDc5MzUgMjMuNTEyMy4xNjkzMyAyMy42Njk0LjI1OTMxMSAyMy44MDA0LjM4ODgwOSAyMy44OTIxLjU0NDk4NkwyNS4yODcxIDMuMzY4OTkgMjguNDAzMSAzLjgyMjk5QzI4LjU4NjYgMy44NTA0OCAyOC43NTg5IDMuOTI4NTMgMjguOTAwNiA0LjA0ODM4IDI5LjA0MjMgNC4xNjgyMyAyOS4xNDc5IDQuMzI1MTYgMjkuMjA1NSA0LjUwMTYgMjkuMjYzMSA0LjY3ODA0IDI5LjI3MDUgNC44NjcwMyAyOS4yMjY4IDUuMDQ3NDIgMjkuMTgzMSA1LjIyNzgxIDI5LjA5IDUuMzkyNDcgMjguOTU4MSA1LjUyMjk5TDI2LjcwMjEgNy43MjI5OSAyNy4yMzQxIDEwLjgyM0MyNy4yNTk5IDEwLjk2NjcgMjcuMjU0IDExLjExNDQgMjcuMjE2NiAxMS4yNTU2IDI3LjE3OTMgMTEuMzk2OCAyNy4xMTE1IDExLjUyODEgMjcuMDE4IDExLjY0MDIgMjYuOTI0NSAxMS43NTI0IDI2LjgwNzYgMTEuODQyOCAyNi42NzU0IDExLjkwNDkgMjYuNTQzMyAxMS45NjcxIDI2LjM5OTEgMTEuOTk5NSAyNi4yNTMxIDEyek00Mi4yNTMxIDQyQzQyLjA5MSA0MS45OTk5IDQxLjkzMTQgNDEuOTYwNCA0MS43ODgxIDQxLjg4NUwzOS4wMDAxIDQwLjQyIDM2LjIxMjEgNDEuODg1QzM2LjA0NjkgNDEuOTcxNSAzNS44NjA5IDQyLjAxMDIgMzUuNjc1IDQxLjk5NjYgMzUuNDg5IDQxLjk4MzEgMzUuMzEwNiA0MS45MTc4IDM1LjE1OTggNDEuODA4MiAzNS4wMDkgNDEuNjk4NiAzNC44OTE5IDQxLjU0OSAzNC44MjE2IDQxLjM3NjMgMzQuNzUxMyA0MS4yMDM2IDM0LjczMDcgNDEuMDE0OCAzNC43NjIxIDQwLjgzMUwzNS4yOTQxIDM3LjczMSAzMy4wMzgxIDM1LjUzMUMzMi45MDYxIDM1LjQwMDUgMzIuODEzIDM1LjIzNTggMzIuNzY5MyAzNS4wNTU0IDMyLjcyNTYgMzQuODc1IDMyLjczMyAzNC42ODYgMzIuNzkwNiAzNC41MDk2IDMyLjg0ODIgMzQuMzMzMiAzMi45NTM4IDM0LjE3NjIgMzMuMDk1NSAzNC4wNTY0IDMzLjIzNzIgMzMuOTM2NSAzMy40MDk1IDMzLjg1ODUgMzMuNTkzMSAzMy44MzFMMzYuNzA5MSAzMy4zNzcgMzguMTAwMSAzMC41NDVDMzguMTkxNyAzMC4zODg4IDM4LjMyMjcgMzAuMjU5MyAzOC40Nzk4IDMwLjE2OTMgMzguNjM3IDMwLjA3OTQgMzguODE0OSAzMC4wMzIgMzguOTk2MSAzMC4wMzIgMzkuMTc3MiAzMC4wMzIgMzkuMzU1MSAzMC4wNzk0IDM5LjUxMjMgMzAuMTY5MyAzOS42Njk0IDMwLjI1OTMgMzkuODAwNCAzMC4zODg4IDM5Ljg5MjEgMzAuNTQ1TDQxLjI4NzEgMzMuMzY5IDQ0LjQwMzEgMzMuODIzQzQ0LjU4NjYgMzMuODUwNSA0NC43NTg5IDMzLjkyODUgNDQuOTAwNiAzNC4wNDg0IDQ1LjA0MjMgMzQuMTY4MiA0NS4xNDc5IDM0LjMyNTIgNDUuMjA1NSAzNC41MDE2IDQ1LjI2MzEgMzQuNjc4IDQ1LjI3MDUgMzQuODY3IDQ1LjIyNjggMzUuMDQ3NCA0NS4xODMxIDM1LjIyNzggNDUuMDkgMzUuMzkyNSA0NC45NTgxIDM1LjUyM0w0Mi43MDIxIDM3LjcyMyA0My4yMzQxIDQwLjgyM0M0My4yNTk5IDQwLjk2NjcgNDMuMjU0IDQxLjExNDQgNDMuMjE2NiA0MS4yNTU2IDQzLjE3OTMgNDEuMzk2OCA0My4xMTE1IDQxLjUyODEgNDMuMDE4IDQxLjY0MDIgNDIuOTI0NSA0MS43NTI0IDQyLjgwNzYgNDEuODQyOCA0Mi42NzU0IDQxLjkwNDkgNDIuNTQzMyA0MS45NjcxIDQyLjM5OTEgNDEuOTk5NSA0Mi4yNTMxIDQyeiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik05IDE0QzguMjA0MzUgMTQgNy40NDEyOSAxMy42ODM5IDYuODc4NjggMTMuMTIxMyA2LjMxNjA3IDEyLjU1ODcgNiAxMS43OTU2IDYgMTEgNiAxMC43MzQ4IDUuODk0NjQgMTAuNDgwNCA1LjcwNzExIDEwLjI5MjkgNS41MTk1NyAxMC4xMDU0IDUuMjY1MjIgMTAgNSAxMCA0LjczNDc4IDEwIDQuNDgwNDMgMTAuMTA1NCA0LjI5Mjg5IDEwLjI5MjkgNC4xMDUzNiAxMC40ODA0IDQgMTAuNzM0OCA0IDExIDQgMTEuNzk1NiAzLjY4MzkzIDEyLjU1ODcgMy4xMjEzMiAxMy4xMjEzIDIuNTU4NzEgMTMuNjgzOSAxLjc5NTY1IDE0IDEgMTQgLjczNDc4NCAxNCAuNDgwNDMgMTQuMTA1NC4yOTI4OTMgMTQuMjkyOS4xMDUzNTcgMTQuNDgwNCAwIDE0LjczNDggMCAxNSAwIDE1LjI2NTIuMTA1MzU3IDE1LjUxOTYuMjkyODkzIDE1LjcwNzEuNDgwNDMgMTUuODk0Ni43MzQ3ODQgMTYgMSAxNiAxLjc5NTY1IDE2IDIuNTU4NzEgMTYuMzE2MSAzLjEyMTMyIDE2Ljg3ODcgMy42ODM5MyAxNy40NDEzIDQgMTguMjA0NCA0IDE5IDQgMTkuMjY1MiA0LjEwNTM2IDE5LjUxOTYgNC4yOTI4OSAxOS43MDcxIDQuNDgwNDMgMTkuODk0NiA0LjczNDc4IDIwIDUgMjAgNS4yNjUyMiAyMCA1LjUxOTU3IDE5Ljg5NDYgNS43MDcxMSAxOS43MDcxIDUuODk0NjQgMTkuNTE5NiA2IDE5LjI2NTIgNiAxOSA2IDE4LjIwNDQgNi4zMTYwNyAxNy40NDEzIDYuODc4NjggMTYuODc4NyA3LjQ0MTI5IDE2LjMxNjEgOC4yMDQzNSAxNiA5IDE2IDkuMjY1MjIgMTYgOS41MTk1NyAxNS44OTQ2IDkuNzA3MTEgMTUuNzA3MSA5Ljg5NDY0IDE1LjUxOTYgMTAgMTUuMjY1MiAxMCAxNSAxMCAxNC43MzQ4IDkuODk0NjQgMTQuNDgwNCA5LjcwNzExIDE0LjI5MjkgOS41MTk1NyAxNC4xMDU0IDkuMjY1MjIgMTQgOSAxNHpNNDUgMTMuOTg3QzQ0LjIwNDQgMTMuOTg3IDQzLjQ0MTMgMTMuNjcwOSA0Mi44Nzg3IDEzLjEwODMgNDIuMzE2MSAxMi41NDU3IDQyIDExLjc4MjYgNDIgMTAuOTg3IDQyIDEwLjcyMTggNDEuODk0NiAxMC40Njc0IDQxLjcwNzEgMTAuMjc5OSA0MS41MTk2IDEwLjA5MjQgNDEuMjY1MiA5Ljk4NyA0MSA5Ljk4NyA0MC43MzQ4IDkuOTg3IDQwLjQ4MDQgMTAuMDkyNCA0MC4yOTI5IDEwLjI3OTkgNDAuMTA1NCAxMC40Njc0IDQwIDEwLjcyMTggNDAgMTAuOTg3IDQwIDExLjc4MjYgMzkuNjgzOSAxMi41NDU3IDM5LjEyMTMgMTMuMTA4MyAzOC41NTg3IDEzLjY3MDkgMzcuNzk1NiAxMy45ODcgMzcgMTMuOTg3IDM2LjczNDggMTMuOTg3IDM2LjQ4MDQgMTQuMDkyNCAzNi4yOTI5IDE0LjI3OTkgMzYuMTA1NCAxNC40Njc0IDM2IDE0LjcyMTggMzYgMTQuOTg3IDM2IDE1LjI1MjIgMzYuMTA1NCAxNS41MDY2IDM2LjI5MjkgMTUuNjk0MSAzNi40ODA0IDE1Ljg4MTYgMzYuNzM0OCAxNS45ODcgMzcgMTUuOTg3IDM3Ljc5NTYgMTUuOTg3IDM4LjU1ODcgMTYuMzAzMSAzOS4xMjEzIDE2Ljg2NTcgMzkuNjgzOSAxNy40MjgzIDQwIDE4LjE5MTMgNDAgMTguOTg3IDQwIDE5LjI1MjIgNDAuMTA1NCAxOS41MDY2IDQwLjI5MjkgMTkuNjk0MSA0MC40ODA0IDE5Ljg4MTYgNDAuNzM0OCAxOS45ODcgNDEgMTkuOTg3IDQxLjI2NTIgMTkuOTg3IDQxLjUxOTYgMTkuODgxNiA0MS43MDcxIDE5LjY5NDEgNDEuODk0NiAxOS41MDY2IDQyIDE5LjI1MjIgNDIgMTguOTg3IDQyIDE4LjE5MTMgNDIuMzE2MSAxNy40MjgzIDQyLjg3ODcgMTYuODY1NyA0My40NDEzIDE2LjMwMzEgNDQuMjA0NCAxNS45ODcgNDUgMTUuOTg3IDQ1LjI2NTIgMTUuOTg3IDQ1LjUxOTYgMTUuODgxNiA0NS43MDcxIDE1LjY5NDEgNDUuODk0NiAxNS41MDY2IDQ2IDE1LjI1MjIgNDYgMTQuOTg3IDQ2IDE0LjcyMTggNDUuODk0NiAxNC40Njc0IDQ1LjcwNzEgMTQuMjc5OSA0NS41MTk2IDE0LjA5MjQgNDUuMjY1MiAxMy45ODcgNDUgMTMuOTg3eiIgZmlsbD0iI0U2QTExNyIvPjxwYXRoIGQ9Ik0xOS4wMDAyIDI4QzE4LjgxMzYgMjguMDAxIDE4LjYzMDMgMjcuOTQ5NyAxOC40NzEzIDI3Ljg1MiAxOC4zMTIyIDI3Ljc1NDMgMTguMTgzNyAyNy42MTQgMTguMTAwMiAyNy40NDcgMTQuNDUzMiAyMC4xNTMgOS4yMDgyMyAxOS45OSA4Ljk4NjIzIDE5Ljk4NyA4Ljg1NDkxIDE5Ljk4NjQgOC43MjQ5OSAxOS45NTk5IDguNjAzODkgMTkuOTA5MSA4LjQ4Mjc5IDE5Ljg1ODMgOC4zNzI4OCAxOS43ODQyIDguMjgwNDQgMTkuNjkwOSA4LjA5Mzc1IDE5LjUwMjUgNy45ODk1NCAxOS4yNDc3IDcuOTkwNzMgMTguOTgyNSA3Ljk5MTkzIDE4LjcxNzMgOC4wOTg0MyAxOC40NjM0IDguMjg2ODEgMTguMjc2NyA4LjQ3NTE5IDE4LjA5IDguNzMwMDIgMTcuOTg1OCA4Ljk5NTIzIDE3Ljk4NyA5LjI2NTIzIDE3Ljk4NyAxNS42NjAyIDE4LjA5OSAxOS44ODgyIDI2LjU1MyAxOS45NjQyIDI2LjcwNDkgMjAuMDAwMSAyNi44NzM4IDE5Ljk5MjggMjcuMDQzNSAxOS45ODU0IDI3LjIxMzIgMTkuOTM0OSAyNy4zNzgzIDE5Ljg0NiAyNy41MjMgMTkuNzU3MiAyNy42Njc4IDE5LjYzMjkgMjcuNzg3NiAxOS40ODQ5IDI3Ljg3MTEgMTkuMzM2OSAyNy45NTQ1IDE5LjE3MDEgMjcuOTk4OSAxOS4wMDAyIDI4ek0yNyAyOEMyNi44Mjk2IDI3Ljk5OTkgMjYuNjYyIDI3Ljk1NjMgMjYuNTEzMiAyNy44NzMzIDI2LjM2NDQgMjcuNzkwMiAyNi4yMzkzIDI3LjY3MDYgMjYuMTQ5OCAyNy41MjU2IDI2LjA2MDIgMjcuMzgwNiAyNi4wMDkyIDI3LjIxNTIgMjYuMDAxNSAyNy4wNDUgMjUuOTkzOCAyNi44NzQ4IDI2LjAyOTggMjYuNzA1NCAyNi4xMDYgMjYuNTUzIDMwLjMzMyAxOC4xIDM2LjcyOCAxNy45ODggMzcgMTcuOTg3IDM3LjI2NTIgMTcuOTg3IDM3LjUxOTUgMTguMDkyNCAzNy43MDcxIDE4LjI3OTkgMzcuODk0NiAxOC40Njc0IDM4IDE4LjcyMTggMzggMTguOTg3IDM4IDE5LjI1MjIgMzcuODk0NiAxOS41MDY2IDM3LjcwNzEgMTkuNjk0MSAzNy41MTk1IDE5Ljg4MTYgMzcuMjY1MiAxOS45ODcgMzcgMTkuOTg3IDM2Ljc4NSAxOS45ODcgMzEuNTQgMjAuMTUzIDI3Ljg5MyAyNy40NDcgMjcuODEgMjcuNjEyOSAyNy42ODI2IDI3Ljc1MjUgMjcuNTI0OSAyNy44NTAyIDI3LjM2NzIgMjcuOTQ3OCAyNy4xODU0IDI3Ljk5OTcgMjcgMjh6IiBmaWxsPSIjMjYyNjMzIi8+PC9zdmc+'); --icon-Tick: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-TickSolid: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0ibSA2MS40NCwwIGMgMzMuOTMzLDAgNjEuNDQxLDI3LjUwNyA2MS40NDEsNjEuNDM5IDAsMzMuOTMzIC0yNy41MDgsNjEuNDQgLTYxLjQ0MSw2MS40NCBDIDI3LjUwOCwxMjIuODggMCw5NS4zNzIgMCw2MS40MzkgMCwyNy41MDcgMjcuNTA4LDAgNjEuNDQsMCBaIE0gMzQuMTA2LDY3LjY3OCAzNC4wOTEsNjcuNjY0IGMgLTAuNzg1LC0wLjcxOCAtMS4yMDcsLTEuNjg1IC0xLjI1NiwtMi42NjkgLTAuMDQ5LC0wLjk4MiAwLjI3NSwtMS45ODUgMC45ODQsLTIuNzc3IDAuMDEsLTAuMDExIDAuMDE5LC0wLjAyMSAwLjAyOSwtMC4wMzEgMC43MTcsLTAuNzg0IDEuNjg0LC0xLjIwNyAyLjY2OCwtMS4yNTYgMC45ODksLTAuMDQ5IDEuOTk4LDAuMjggMi43OTIsMC45OTggTCA1Mi4yNjQsNzMuNjc3IDgzLjM1Myw0MS4xMTggdiAwIGMgMC43NCwtMC43NzYgMS43MjMsLTEuMTggMi43MTksLTEuMjA0IDAuOTkyLC0wLjAyNSAxLjk5NCwwLjMyOSAyLjc3MSwxLjA2NyB2IDEwZS00IGMgMC43NzcsMC43MzkgMS4xOCwxLjcyNCAxLjIwNSwyLjcxOCAwLjAyNSwwLjk5MyAtMC4zMywxLjk5NyAtMS4wNjgsMi43NzMgTCA1NS4yNzksODEuNzY5IGMgLTAuMDIzLDAuMDI0IC0wLjA0OCwwLjA0NyAtMC4wNzMsMC4wNjcgLTAuNzE1LDAuNzE1IC0xLjY0OSwxLjA5NSAtMi41OTgsMS4xMyAtMC45NzQsMC4wMzcgLTEuOTYzLC0wLjI5MyAtMi43NDQsLTEgTCAzNC4xMTgsNjcuNjg4IFoiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09Im1hdHJpeCguMTMwMiAwIDAgLjEzMDIgMCAwKSIvPjwvc3ZnPg=='); --icon-Undo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuMzU1MjI1NjIsNSBMNi41LDUgQzYuNzc2MTQyMzcsNSA3LDUuMjIzODU3NjMgNyw1LjUgQzcsNS43NzYxNDIzNyA2Ljc3NjE0MjM3LDYgNi41LDYgTDEuNTEwMTA5OTksNiBDMS40ODY3NTIwNiw2LjAwMDQ5MTk4IDEuNDYzMjU2NTksNS45OTkzNDQxNCAxLjQzOTc5Mzg5LDUuOTk2NDk5MjUgQzEuMzYwMjgyMTYsNS45ODY4NzA1OCAxLjI4NjUwNTA1LDUuOTU4NjY1NjMgMS4yMjI5NzM2NSw1LjkxNjMwNDg0IEMxLjA5OTIxNTYzLDUuODM0MTc1NDcgMS4wMjE4NjQ3MSw1LjcwMzQ3ODggMS4wMDM5MTQxNyw1LjU2Mjg2NjI5IEMxLjAwMTgxMDM5LDUuNTQ2MDk2MDIgMS4wMDA1MzcxOCw1LjUyOTA2NzQgMS4wMDAxMzcwNCw1LjUxMTgyMjkyIEMwLjk5OTg1Mzk4Nyw1LjUwNDI3MzIxIDAuOTk5ODQwMjk5LDUuNDk2NzA0NiAxLDUuNDg5MTI2OTMgTDEsMC41IEMxLDAuMjIzODU3NjI1IDEuMjIzODU3NjMsMCAxLjUsMCBDMS43NzYxNDIzNywwIDIsMC4yMjM4NTc2MjUgMiwwLjUgTDIsMy43NzQ2OTMxNiBDMy4zNjg1NDA5LDIuMDM5NDAzNDMgNS4zMjkwNzU2LDEgNy41LDEgQzExLjY0MjE0MjQsMSAxNSw0LjM1Nzg1NzYzIDE1LDguNSBDMTUsMTIuNjQyMTQyNCAxMS42NDIxNDI0LDE2IDcuNSwxNiBDNy4yMjM4NTc2MywxNiA3LDE1Ljc3NjE0MjQgNywxNS41IEM3LDE1LjIyMzg1NzYgNy4yMjM4NTc2MywxNSA3LjUsMTUgQzExLjA4OTg1NzYsMTUgMTQsMTIuMDg5ODU3NiAxNCw4LjUgQzE0LDQuOTEwMTQyMzcgMTEuMDg5ODU3NiwyIDcuNSwyIEM1LjQxNTc0NjU0LDIgMy41NDU1NTE2MywzLjEzMDY0MDcgMi4zNTUyMjU2Miw1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); diff --git a/static/ui-icons/UI/Sparks.svg b/static/ui-icons/UI/Sparks.svg new file mode 100644 index 00000000..578a2cc2 --- /dev/null +++ b/static/ui-icons/UI/Sparks.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/stubs/app/client/ui/ProductUpgrades.ts b/stubs/app/client/ui/ProductUpgrades.ts new file mode 100644 index 00000000..5e078ffb --- /dev/null +++ b/stubs/app/client/ui/ProductUpgrades.ts @@ -0,0 +1,27 @@ +import type {AppModel} from 'app/client/models/AppModel'; +import {commonUrls} from 'app/common/gristUrls'; +import {Disposable} from 'grainjs'; + +export function buildUpgradeNudge(options: { + onClose: () => void; + onUpgrade: () => void +}) { + return null; +} + +export function buildNewSiteModal(owner: Disposable, current: string | null) { + window.location.href = commonUrls.plans; +} + +export function buildUpgradeModal(owner: Disposable, planName: string) { + window.location.href = commonUrls.plans; +} + +export class UpgradeButton extends Disposable { + constructor(appModel: AppModel) { + super(); + } + public buildDom() { + return null; + } +} diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 51ffe813..4c328efc 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -91,7 +91,7 @@ export async function main() { }, { setUserAsOwner: false, useNewPlan: true, - planType: 'free' + planType: 'teamFree' }); } } diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 760e3143..bebeec62 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -24,6 +24,7 @@ import { HomeUtil } from 'test/nbrowser/homeUtil'; import { server } from 'test/nbrowser/testServer'; import { Cleanup } from 'test/nbrowser/testUtils'; import * as testUtils from 'test/server/testUtils'; +import type { AssertionError } from 'assert'; // tslint:disable:no-namespace // Wrap in a namespace so that we can apply stackWrapOwnMethods to all the exports together. @@ -92,17 +93,25 @@ export function exactMatch(value: string): RegExp { } /** - * Helper function that creates a regular expression to match the begging of the string. + * Helper function that creates a regular expression to match the beginning of the string. */ export function startsWith(value: string): RegExp { return new RegExp(`^${escapeRegExp(value)}`); } + +/** + * Helper function that creates a regular expression to match the anywhere in of the string. + */ + export function contains(value: string): RegExp { + return new RegExp(`${escapeRegExp(value)}`); +} + /** * Helper to scroll an element into view. */ export function scrollIntoView(elem: WebElement): Promise { - return driver.executeScript((el: any) => el.scrollIntoView(), elem); + return driver.executeScript((el: any) => el.scrollIntoView({behavior: 'auto'}), elem); } /** @@ -571,7 +580,7 @@ export async function setApiKey(username: string, apiKey?: string) { /** * Reach into the DB to set the given org to use the given billing plan product. */ -export async function updateOrgPlan(orgName: string, productName: string = 'professional') { +export async function updateOrgPlan(orgName: string, productName: string = 'team') { const dbManager = await server.getDatabase(); const db = dbManager.connection.manager; const dbOrg = await db.findOne(Organization, {where: {name: orgName}, @@ -747,12 +756,13 @@ export async function userActionsVerify(expectedUserActions: unknown[]): Promise await driver.executeScript("return window.gristApp.comm.userActionsFetchAndReset()"), expectedUserActions); } catch (err) { - if (!Array.isArray(err.actual)) { + const assertError = err as AssertionError; + if (!Array.isArray(assertError.actual)) { throw new Error('userActionsVerify: no user actions, run userActionsCollect() first'); } - err.actual = err.actual.map((a: any) => JSON.stringify(a) + ",").join("\n"); - err.expected = err.expected.map((a: any) => JSON.stringify(a) + ",").join("\n"); - assert.deepEqual(err.actual, err.expected); + assertError.actual = assertError.actual.map((a: any) => JSON.stringify(a) + ",").join("\n"); + assertError.expected = assertError.expected.map((a: any) => JSON.stringify(a) + ",").join("\n"); + assert.deepEqual(assertError.actual, assertError.expected); throw err; } } @@ -2413,6 +2423,10 @@ export async function setWidgetUrl(url: string) { await waitForServer(); } +export async function toggleNewDeal(on = true) { + await driver.executeScript(`NEW_DEAL.set(${on ? 'true' : 'false'});`); +} + } // end of namespace gristUtils stackWrapOwnMethods(gristUtils); diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts index 7aa93fef..ec66c8aa 100644 --- a/test/nbrowser/homeUtil.ts +++ b/test/nbrowser/homeUtil.ts @@ -101,7 +101,7 @@ export class HomeUtil { if (options.cacheCredentials) { // Take this opportunity to cache access info. if (!this._apiKey.has(email)) { - await this.driver.get(this.server.getUrl(org, '')); + await this.driver.get(this.server.getUrl(org || 'docs', '')); this._apiKey.set(email, await this._getApiKey()); } }