(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
This commit is contained in:
Jarosław Sadziński
2022-06-08 19:54:00 +02:00
parent 3b4d936013
commit d92a761f6e
27 changed files with 841 additions and 1328 deletions

121
app/client/lib/Validator.ts Normal file
View File

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

View File

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

View File

@@ -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);
}
// 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 {
// 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');
}
// Show the billing summary page after submission
await urlState().pushUrl({ billing: 'billing', params: undefined });
throw new Error('BillingPage _submit error: no task in progress');
}
} 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');
}
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());
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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)')
]),
cssDocMenu(
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;
// 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})
),
// 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')
return [
// Hide the sort option only when showing intro.
((showIntro && page === 'all') ? null :
buildPrefs(viewSettings, {hideSort: showIntro})
),
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'),
)
),
(
(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;
`);

View File

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

View File

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

View File

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