(core) Product update popups and hosted stripe integration

Summary:
- Showing nudge to individual users to sign up for free team plan.
- Implementing billing page to upgrade from free team to pro.
- New modal with upgrade options and free team site signup.
- Integrating Stripe-hosted UI for checkout and plan management.

Test Plan: updated tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3456
pull/214/head
Jarosław Sadziński 2 years ago
parent 3b4d936013
commit d92a761f6e

@ -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<boolean> | void | Promise<void>)
/**
* 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<boolean> or void. Booleans are straightforwards.
// When validator has a void/Promise<void> 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};
`);

@ -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<PageType>;
notifier: Notifier;
planName: string|null;
refreshOrgUsage(): Promise<void>;
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.
*/

@ -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<string|null>;
@ -15,8 +13,6 @@ export interface BillingModel {
// Client-friendly version of the IBillingSubscription fetched from the server.
// See ISubscriptionModel for details.
readonly subscription: Observable<ISubscriptionModel|undefined>;
// Payment card fetched from the server.
readonly card: Observable<IBillingCard|null>;
readonly currentSubpage: Computed<BillingSubPage|undefined>;
// 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<boolean>;
// 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<void>;
// Remove billing account manager.
removeManager(email: string): Promise<void>;
// Remove the payment card from the account.
removeCard(): Promise<void>;
// Returns a boolean indicating if the org domain string is available.
isDomainAvailable(domain: string): Promise<boolean>;
// Triggered when submit is clicked on the payment page. Performs the API billing account
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
submitPaymentPage(formData?: IFormData): Promise<void>;
// Fetches coupon information for a valid `discountCode`.
fetchSignupCoupon(discountCode: string): Promise<IBillingCoupon>;
// Fetches the effective tax rate for the address in the given form.
fetchSignupTaxRate(formData: IFormData): Promise<void>;
// Fetches subscription data associated with the given org, if the pages are associated with an
// org and the user is a plan manager. Otherwise, fetches available plans only.
fetchData(forceReload?: boolean): Promise<void>;
// Triggered when submit is clicked on the payment page. Performs the API billing account
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
submitPaymentPage(formData?: IFormData): Promise<void>;
// Cancels current subscription.
cancelCurrentPlan(): Promise<void>;
// Retrieves customer portal session URL.
getCustomerPortalUrl(): string;
// Renews plan (either by opening customer portal or creating Stripe Checkout session)
renewPlan(): string;
}
export interface ISubscriptionModel extends Omit<IBillingSubscription, 'plans'|'card'> {
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<ISubscriptionModel|undefined> = Observable.create(this, undefined);
// Payment card fetched from the server.
public readonly card: Observable<IBillingCard|null> = Observable.create(this, null);
public readonly currentSubpage: Computed<BillingSubPage|undefined> =
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<IBillingPlan|undefined> =
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<void> {
try {
await this._billingAPI.removeCard();
this.card.set(null);
} catch (err) {
reportError(err);
}
}
public isDomainAvailable(domain: string): Promise<boolean> {
return this._billingAPI.isDomainAvailable(domain);
}
public async fetchSignupCoupon(discountCode: string): Promise<IBillingCoupon> {
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<void> {
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<void> {
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<void> {
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<void> {
if (this.plans.get().length === 0) {
this.plans.set(await this._billingAPI.getPlans());
}
}
}

@ -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,

@ -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),

@ -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<IBillingAddress>;
settings?: Partial<IBillingOrgSettings>;
// Note that the card name is the only value that may be initialized, since the other card
// information is sensitive.
card?: Partial<IBillingCard>;
coupon?: Partial<IBillingCoupon>;
}
// An object containing a function to check the validity of its observable value.
@ -51,69 +28,34 @@ interface IValidated<T> {
}
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<IFormData> {
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<any> = Observable.create(this, null);
private readonly _expiryElement: Observable<any> = Observable.create(this, null);
private readonly _cvcElement: Observable<any> = Observable.create(this, null);
private readonly _name: IValidated<string> = createValidated(this, checkRequired('Name'));
constructor(private readonly _options: {
showHeader: boolean;
autofill?: Partial<IBillingCard>;
}) {
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 youve 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<any>, 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<string> = createValidated(this, checkRequired('Address'));
private readonly _address2: IValidated<string> = createValidated(this, () => undefined);
private readonly _city: IValidated<string> = createValidated(this, checkRequired('City'));
private readonly _state: IValidated<string> = createValidated(this, checkFunc(
(val) => !this._isUS.get() || Boolean(val), `State is required.`));
private readonly _postal: IValidated<string> = createValidated(this, checkFunc(
(val) => !this._isUS.get() || Boolean(val), 'Zip code is required.'));
private readonly _countryCode: IValidated<string> = createValidated(this, checkRequired('Country'));
private _isUS = Computed.create(this, this._countryCode.value, (use, code) => (code === 'US'));
private readonly _countries: Array<IOption<string>> = getCountries();
constructor(private readonly _options: {
showHeader: boolean;
autofill?: Partial<IBillingAddress>;
}) {
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<IFilledBillingAddress|undefined> {
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<string> = createValidated(this, () => undefined);
constructor(
private readonly _billingModel: BillingModel,
private readonly _options: { autofill?: Partial<IBillingCoupon>; }
) {
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<IOption<string>> {
// 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;
`);

File diff suppressed because it is too large Load Diff

@ -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<string|null>(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;
`);

@ -14,6 +14,7 @@ export const docList = styled('div', `
padding: 32px 64px 24px 64px;
overflow-y: auto;
position: relative;
display: flex;
&:after {
content: "";

@ -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'),

@ -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",

@ -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<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
// Filled address info from the client. Fields can be blank strings.
export type IFilledBillingAddress = NonNullableProperties<IBillingAddress>;
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
// 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<boolean>;
getCoupon(promotionCode: string): Promise<IBillingCoupon>;
getTaxRate(address: IBillingAddress): Promise<number>;
getPlans(): Promise<IBillingPlan[]>;
getSubscription(): Promise<IBillingSubscription>;
getBillingAccount(): Promise<FullBillingAccount>;
// 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<OrganizationWithoutAccessInfo>;
setCard(tokenId: string): Promise<void>;
removeCard(): Promise<void>;
setSubscription(planId: string, options: {
tokenId?: string,
address?: IBillingAddress,
settings?: IBillingOrgSettings,
}): Promise<void>;
updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise<void>;
updateBillingManagers(delta: ManagerDelta): Promise<void>;
updateSettings(settings: IBillingOrgSettings): Promise<void>;
subscriptionStatus(planId: string): Promise<boolean>;
createFreeTeam(name: string, domain: string): Promise<string>;
createTeam(name: string, domain: string): Promise<string>;
upgrade(): Promise<string>;
cancelCurrentPlan(): Promise<void>;
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<IBillingCoupon> {
return this.requestJson(`${this._url}/api/billing/coupon/${promotionCode}`, {
method: 'GET',
});
}
public async getTaxRate(address: IBillingAddress): Promise<number> {
return this.requestJson(`${this._url}/api/billing/tax`, {
method: 'POST',
body: JSON.stringify({ address })
});
}
public async getPlans(): Promise<IBillingPlan[]> {
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<OrganizationWithoutAccessInfo> {
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<void> {
await this.request(`${this._url}/api/billing/subscription`, {
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, {
method: 'POST',
body: JSON.stringify({ ...options, planId })
body: JSON.stringify({ settings })
});
}
public async removeSubscription(): Promise<void> {
await this.request(`${this._url}/api/billing/subscription`, {method: 'DELETE'});
public async updateBillingManagers(delta: ManagerDelta): Promise<void> {
await this.request(`${this._url}/api/billing/managers`, {
method: 'PATCH',
body: JSON.stringify({delta})
});
}
public async setCard(tokenId: string): Promise<void> {
await this.request(`${this._url}/api/billing/card`, {
public async createFreeTeam(name: string, domain: string): Promise<string> {
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<void> {
await this.request(`${this._url}/api/billing/card`, {method: 'DELETE'});
public async createTeam(name: string, domain: string): Promise<string> {
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<void> {
await this.request(`${this._url}/api/billing/address`, {
public async upgrade(): Promise<string> {
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<void> {
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<boolean> {
const data = await this.requestJson(`${this._url}/api/billing/status`, {
method: 'POST',
body: JSON.stringify({planId})
});
return data.active;
}
private get _url(): string {

@ -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);
}

@ -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;

@ -68,6 +68,7 @@ export interface BillingAccount {
individual: boolean;
product: Product;
isManager: boolean;
inGoodStanding: boolean;
externalOptions?: {
invoiceId?: string;
};

@ -73,7 +73,7 @@ export function addOrg(
userId: number,
props: Partial<OrganizationProperties>,
options?: {
planType?: 'free'
planType?: string,
}
): Promise<number> {
return dbManager.connection.transaction(async manager => {

@ -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.
*/

@ -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<OrganizationProperties>,
options: { setUserAsOwner: boolean,
useNewPlan: boolean,
planType?: 'free',
planType?: string,
externalId?: string,
externalOptions?: ExternalBillingOptions },
transaction?: EntityManager): Promise<QueryResult<number>> {
@ -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')

@ -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<string> {
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);
}
/**

@ -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<void>;

@ -181,7 +181,7 @@ export async function sendReply<T>(
result: QueryResult<T>,
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<T>(
});
}
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<T>(data: T, allowedFields?: Set<string>): 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;
}
/**

File diff suppressed because one or more lines are too long

@ -0,0 +1,11 @@
<svg width="46" height="44" viewBox="0 0 46 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.6248 30.219C33.5575 29.4612 32.2893 29.0369 30.9808 29C29.0418 29.036 26.0748 29.992 23.9998 34.747V13.987C23.9998 13.7218 23.8944 13.4674 23.7069 13.2799C23.5194 13.0924 23.265 12.987 22.9998 12.987C22.7346 12.987 22.4802 13.0924 22.2927 13.2799C22.1052 13.4674 21.9998 13.7218 21.9998 13.987V34.747C19.9248 29.992 16.9578 29.036 15.0188 29H14.9248C13.6437 29.0292 12.4032 29.4555 11.3748 30.22C11.2722 30.3021 11.1868 30.4035 11.1235 30.5186C11.0602 30.6337 11.0201 30.7601 11.0056 30.8907C10.9911 31.0212 11.0025 31.1534 11.0391 31.2795C11.0757 31.4057 11.1367 31.5234 11.2188 31.626C11.3009 31.7286 11.4024 31.814 11.5174 31.8773C11.6325 31.9406 11.7589 31.9807 11.8895 31.9952C12.0201 32.0097 12.1522 31.9983 12.2784 31.9617C12.4045 31.9251 12.5222 31.8641 12.6248 31.782C13.2961 31.2973 14.0972 31.0249 14.9248 31H14.9818C18.5488 31.065 21.1088 35.482 22.0078 43.117C22.0367 43.3601 22.1538 43.5841 22.3368 43.7466C22.5198 43.9091 22.756 43.9989 23.0008 43.9989C23.2456 43.9989 23.4818 43.9091 23.6648 43.7466C23.8478 43.5841 23.9649 43.3601 23.9938 43.117C24.8938 35.482 27.4528 31.065 31.0198 31H31.0768C31.9043 31.0282 32.7047 31.3014 33.3768 31.785C33.5847 31.9458 33.8476 32.0183 34.1085 31.9868C34.3695 31.9552 34.6075 31.8222 34.7712 31.6165C34.9348 31.4108 35.0109 31.149 34.9829 30.8876C34.955 30.6263 34.8252 30.3864 34.6218 30.22L34.6248 30.219Z" fill="#262633"/>
<path d="M10.2531 42C10.091 41.9999 9.93145 41.9604 9.78806 41.885L7.00006 40.42L4.21206 41.885C4.04694 41.9715 3.86091 42.0102 3.67498 41.9966C3.48905 41.9831 3.31061 41.9178 3.15982 41.8082C3.00902 41.6986 2.89187 41.549 2.82158 41.3763C2.7513 41.2036 2.73068 41.0147 2.76206 40.831L3.29406 37.731L1.03806 35.531C0.906082 35.4005 0.813035 35.2358 0.76933 35.0554C0.725625 34.875 0.732986 34.686 0.790589 34.5096C0.848192 34.3332 0.953764 34.1762 1.09549 34.0564C1.23721 33.9365 1.4095 33.8585 1.59306 33.831L4.70906 33.377L6.10906 30.553C6.20074 30.3968 6.33166 30.2673 6.48883 30.1773C6.646 30.0873 6.82395 30.04 7.00506 30.04C7.18616 30.04 7.36411 30.0873 7.52128 30.1773C7.67845 30.2673 7.80937 30.3968 7.90106 30.553L9.29605 33.377L12.4121 33.831C12.5956 33.8585 12.7679 33.9365 12.9096 34.0564C13.0513 34.1762 13.1569 34.3332 13.2145 34.5096C13.2721 34.686 13.2795 34.875 13.2358 35.0554C13.1921 35.2358 13.099 35.4005 12.9671 35.531L10.7111 37.731L11.2431 40.831C11.2677 40.9749 11.2606 41.1224 11.2222 41.2633C11.1837 41.4041 11.1149 41.5348 11.0206 41.6463C10.9262 41.7577 10.8086 41.8471 10.676 41.9082C10.5434 41.9693 10.3991 42.0006 10.2531 42Z" fill="#16B378"/>
<path d="M26.2531 12C26.091 11.9999 25.9315 11.9604 25.7881 11.885L23.0001 10.42L20.2121 11.885C20.0469 11.9715 19.8609 12.0102 19.675 11.9966C19.4891 11.9831 19.3106 11.9178 19.1598 11.8082C19.009 11.6986 18.8919 11.549 18.8216 11.3763C18.7513 11.2036 18.7307 11.0148 18.7621 10.831L19.2941 7.73099L17.0381 5.53099C16.9061 5.40048 16.813 5.23581 16.7693 5.05542C16.7256 4.87503 16.733 4.68604 16.7906 4.5096C16.8482 4.33316 16.9538 4.17623 17.0955 4.05638C17.2372 3.93653 17.4095 3.85848 17.5931 3.83099L20.7091 3.37699L22.1001 0.544986C22.1917 0.388809 22.3227 0.259311 22.4798 0.16933C22.637 0.07935 22.815 0.0320129 22.9961 0.0320129C23.1772 0.0320129 23.3551 0.07935 23.5123 0.16933C23.6694 0.259311 23.8004 0.388809 23.8921 0.544986L25.2871 3.36899L28.4031 3.82299C28.5866 3.85048 28.7589 3.92853 28.9006 4.04838C29.0423 4.16823 29.1479 4.32516 29.2055 4.5016C29.2631 4.67804 29.2705 4.86703 29.2268 5.04742C29.1831 5.22781 29.09 5.39247 28.9581 5.52299L26.7021 7.72299L27.2341 10.823C27.2599 10.9667 27.254 11.1144 27.2166 11.2556C27.1793 11.3968 27.1115 11.5281 27.018 11.6402C26.9245 11.7524 26.8076 11.8428 26.6754 11.9049C26.5433 11.9671 26.3991 11.9995 26.2531 12Z" fill="#16B378"/>
<path d="M42.2531 42C42.091 41.9999 41.9314 41.9604 41.7881 41.885L39.0001 40.42L36.2121 41.885C36.0469 41.9715 35.8609 42.0102 35.675 41.9966C35.489 41.9831 35.3106 41.9178 35.1598 41.8082C35.009 41.6986 34.8919 41.549 34.8216 41.3763C34.7513 41.2036 34.7307 41.0148 34.7621 40.831L35.2941 37.731L33.0381 35.531C32.9061 35.4005 32.813 35.2358 32.7693 35.0554C32.7256 34.875 32.733 34.686 32.7906 34.5096C32.8482 34.3332 32.9538 34.1762 33.0955 34.0564C33.2372 33.9365 33.4095 33.8585 33.5931 33.831L36.7091 33.377L38.1001 30.545C38.1917 30.3888 38.3227 30.2593 38.4798 30.1693C38.637 30.0794 38.8149 30.032 38.9961 30.032C39.1772 30.032 39.3551 30.0794 39.5123 30.1693C39.6694 30.2593 39.8004 30.3888 39.8921 30.545L41.2871 33.369L44.4031 33.823C44.5866 33.8505 44.7589 33.9285 44.9006 34.0484C45.0423 34.1682 45.1479 34.3252 45.2055 34.5016C45.2631 34.678 45.2705 34.867 45.2268 35.0474C45.1831 35.2278 45.09 35.3925 44.9581 35.523L42.7021 37.723L43.2341 40.823C43.2599 40.9667 43.254 41.1144 43.2166 41.2556C43.1793 41.3968 43.1115 41.5281 43.018 41.6402C42.9245 41.7524 42.8076 41.8428 42.6754 41.9049C42.5433 41.9671 42.3991 41.9995 42.2531 42Z" fill="#16B378"/>
<path d="M9 14C8.20435 14 7.44129 13.6839 6.87868 13.1213C6.31607 12.5587 6 11.7956 6 11C6 10.7348 5.89464 10.4804 5.70711 10.2929C5.51957 10.1054 5.26522 10 5 10C4.73478 10 4.48043 10.1054 4.29289 10.2929C4.10536 10.4804 4 10.7348 4 11C4 11.7956 3.68393 12.5587 3.12132 13.1213C2.55871 13.6839 1.79565 14 1 14C0.734784 14 0.48043 14.1054 0.292893 14.2929C0.105357 14.4804 0 14.7348 0 15C0 15.2652 0.105357 15.5196 0.292893 15.7071C0.48043 15.8946 0.734784 16 1 16C1.79565 16 2.55871 16.3161 3.12132 16.8787C3.68393 17.4413 4 18.2044 4 19C4 19.2652 4.10536 19.5196 4.29289 19.7071C4.48043 19.8946 4.73478 20 5 20C5.26522 20 5.51957 19.8946 5.70711 19.7071C5.89464 19.5196 6 19.2652 6 19C6 18.2044 6.31607 17.4413 6.87868 16.8787C7.44129 16.3161 8.20435 16 9 16C9.26522 16 9.51957 15.8946 9.70711 15.7071C9.89464 15.5196 10 15.2652 10 15C10 14.7348 9.89464 14.4804 9.70711 14.2929C9.51957 14.1054 9.26522 14 9 14Z" fill="#E6A117"/>
<path d="M45 13.987C44.2044 13.987 43.4413 13.6709 42.8787 13.1083C42.3161 12.5457 42 11.7826 42 10.987C42 10.7218 41.8946 10.4674 41.7071 10.2799C41.5196 10.0924 41.2652 9.987 41 9.987C40.7348 9.987 40.4804 10.0924 40.2929 10.2799C40.1054 10.4674 40 10.7218 40 10.987C40 11.7826 39.6839 12.5457 39.1213 13.1083C38.5587 13.6709 37.7956 13.987 37 13.987C36.7348 13.987 36.4804 14.0924 36.2929 14.2799C36.1054 14.4674 36 14.7218 36 14.987C36 15.2522 36.1054 15.5066 36.2929 15.6941C36.4804 15.8816 36.7348 15.987 37 15.987C37.7956 15.987 38.5587 16.3031 39.1213 16.8657C39.6839 17.4283 40 18.1913 40 18.987C40 19.2522 40.1054 19.5066 40.2929 19.6941C40.4804 19.8816 40.7348 19.987 41 19.987C41.2652 19.987 41.5196 19.8816 41.7071 19.6941C41.8946 19.5066 42 19.2522 42 18.987C42 18.1913 42.3161 17.4283 42.8787 16.8657C43.4413 16.3031 44.2044 15.987 45 15.987C45.2652 15.987 45.5196 15.8816 45.7071 15.6941C45.8946 15.5066 46 15.2522 46 14.987C46 14.7218 45.8946 14.4674 45.7071 14.2799C45.5196 14.0924 45.2652 13.987 45 13.987Z" fill="#E6A117"/>
<path d="M19.0002 28C18.8136 28.001 18.6303 27.9497 18.4713 27.852C18.3122 27.7543 18.1837 27.614 18.1002 27.447C14.4532 20.153 9.20823 19.99 8.98623 19.987C8.85491 19.9864 8.72499 19.9599 8.60389 19.9091C8.48279 19.8583 8.37288 19.7842 8.28044 19.6909C8.09375 19.5025 7.98954 19.2477 7.99073 18.9825C7.99193 18.7173 8.09843 18.4634 8.28681 18.2767C8.47519 18.09 8.73002 17.9858 8.99523 17.987C9.26523 17.987 15.6602 18.099 19.8882 26.553C19.9642 26.7049 20.0001 26.8738 19.9928 27.0435C19.9854 27.2132 19.9349 27.3783 19.846 27.523C19.7572 27.6678 19.6329 27.7876 19.4849 27.8711C19.3369 27.9545 19.1701 27.9989 19.0002 28Z" fill="#262633"/>
<path d="M27 28C26.8296 27.9999 26.662 27.9563 26.5132 27.8733C26.3644 27.7902 26.2393 27.6706 26.1498 27.5256C26.0602 27.3806 26.0092 27.2152 26.0015 27.045C25.9938 26.8748 26.0298 26.7054 26.106 26.553C30.333 18.1 36.728 17.988 37 17.987C37.2652 17.987 37.5195 18.0924 37.7071 18.2799C37.8946 18.4674 38 18.7218 38 18.987C38 19.2522 37.8946 19.5066 37.7071 19.6941C37.5195 19.8816 37.2652 19.987 37 19.987C36.785 19.987 31.54 20.153 27.893 27.447C27.81 27.6129 27.6826 27.7525 27.5249 27.8502C27.3672 27.9478 27.1854 27.9997 27 28Z" fill="#262633"/>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

@ -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;
}
}

@ -91,7 +91,7 @@ export async function main() {
}, {
setUserAsOwner: false,
useNewPlan: true,
planType: 'free'
planType: 'teamFree'
});
}
}

@ -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<void> {
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);

@ -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());
}
}

Loading…
Cancel
Save