2020-10-02 15:10:00 +00:00
|
|
|
|
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
|
|
|
|
import {reportError} from 'app/client/models/AppModel';
|
2021-10-20 18:18:01 +00:00
|
|
|
|
import {BillingModel} from 'app/client/models/BillingModel';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
import * as css from 'app/client/ui/BillingPageCss';
|
|
|
|
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
2020-10-26 14:45:31 +00:00
|
|
|
|
import {IOption, select} from 'app/client/ui2018/menus';
|
2021-11-04 17:31:08 +00:00
|
|
|
|
import type {ApiError} from 'app/common/ApiError';
|
2021-10-20 18:18:01 +00:00
|
|
|
|
import {IBillingAddress, IBillingCard, IBillingCoupon, IBillingOrgSettings,
|
|
|
|
|
IFilledBillingAddress} from 'app/common/BillingAPI';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
|
|
|
|
import * as roles from 'app/common/roles';
|
|
|
|
|
import {Organization} from 'app/common/UserAPI';
|
2020-10-26 14:45:31 +00:00
|
|
|
|
import {Computed, Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable, styled} from 'grainjs';
|
|
|
|
|
import sortBy = require('lodash/sortBy');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
|
|
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 {
|
2021-10-20 18:18:01 +00:00
|
|
|
|
address?: IFilledBillingAddress;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
card?: IBillingCard;
|
|
|
|
|
token?: string;
|
|
|
|
|
settings?: IBillingOrgSettings;
|
2021-10-20 18:18:01 +00:00
|
|
|
|
coupon?: IBillingCoupon;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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>;
|
2021-10-20 18:18:01 +00:00
|
|
|
|
coupon?: Partial<IBillingCoupon>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An object containing a function to check the validity of its observable value.
|
|
|
|
|
// The get function should return the observable value or throw an error if it is invalid.
|
|
|
|
|
interface IValidated<T> {
|
|
|
|
|
value: Observable<T>;
|
|
|
|
|
checkValidity: (value: T) => void|Promise<void>; // Should throw with message on invalid values.
|
|
|
|
|
isInvalid: Observable<boolean>;
|
|
|
|
|
get: () => T|Promise<T>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class BillingForm extends Disposable {
|
|
|
|
|
private readonly _address: BillingAddressForm|null;
|
2021-10-20 18:18:01 +00:00
|
|
|
|
private readonly _discount: BillingDiscountForm|null;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
private readonly _payment: BillingPaymentForm|null;
|
|
|
|
|
private readonly _settings: BillingSettingsForm|null;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
org: Organization|null,
|
2021-10-20 18:18:01 +00:00
|
|
|
|
billingModel: BillingModel,
|
|
|
|
|
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean, discount: boolean},
|
2020-10-02 15:10:00 +00:00
|
|
|
|
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.
|
2021-10-20 18:18:01 +00:00
|
|
|
|
this._settings = options.settings ? new BillingSettingsForm(billingModel, org, {
|
2020-10-02 15:10:00 +00:00
|
|
|
|
showHeader: count > 1,
|
|
|
|
|
showDomain: options.domain,
|
|
|
|
|
autofill: autofill.settings
|
|
|
|
|
}) : null;
|
|
|
|
|
|
2021-10-20 18:18:01 +00:00
|
|
|
|
// Discount form.
|
|
|
|
|
this._discount = options.discount ? new BillingDiscountForm(billingModel, {
|
|
|
|
|
autofill: autofill.coupon
|
|
|
|
|
}) : null;
|
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
|
// 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,
|
2021-10-20 18:18:01 +00:00
|
|
|
|
this._discount ? this._discount.buildDom() : null,
|
2020-10-02 15:10:00 +00:00
|
|
|
|
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;
|
2021-10-20 18:18:01 +00:00
|
|
|
|
const coupon = this._discount ? await this._discount.getCoupon() : undefined;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
return {
|
|
|
|
|
settings,
|
|
|
|
|
address,
|
2021-10-20 18:18:01 +00:00
|
|
|
|
coupon,
|
2020-10-02 15:10:00 +00:00
|
|
|
|
token: cardInfo ? cardInfo.token : undefined,
|
|
|
|
|
card: cardInfo ? cardInfo.card : undefined
|
|
|
|
|
};
|
|
|
|
|
}
|
2021-11-04 17:31:08 +00:00
|
|
|
|
|
|
|
|
|
// Make a best-effort attempt to focus the element with the error.
|
|
|
|
|
public focusOnError() {
|
|
|
|
|
// We don't have a good way to do it, we just try to do better than nothing. Also we don't
|
|
|
|
|
// have access to the form container, so look at css.inputError element in the full document.
|
|
|
|
|
const elem = document.querySelector(`.${css.paymentBlock.className} .${css.inputError.className}:not(:empty)`);
|
|
|
|
|
const parent = elem?.closest(`.${css.paymentBlock.className}`);
|
|
|
|
|
if (parent) {
|
|
|
|
|
const input: HTMLInputElement|null =
|
|
|
|
|
parent.querySelector(`.${css.billingInput.className}-invalid`) ||
|
|
|
|
|
parent.querySelector('input');
|
|
|
|
|
if (input) {
|
|
|
|
|
input.focus();
|
|
|
|
|
input.select();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Abstract class which includes helper functions for creating a form whose values are verified.
|
|
|
|
|
abstract class BillingSubForm extends Disposable {
|
|
|
|
|
protected readonly formError: Observable<string> = Observable.create(this, '');
|
2021-11-04 17:31:08 +00:00
|
|
|
|
protected shouldAutoFocus = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Creates an input whose value is validated on blur. Input text turns red and the validation
|
|
|
|
|
// error is shown on negative validation.
|
|
|
|
|
protected billingInput(validated: IValidated<string>, ...args: Array<DomArg<any>>) {
|
|
|
|
|
return css.billingInput(validated.value, {onInput: true},
|
|
|
|
|
css.billingInput.cls('-invalid', validated.isInvalid),
|
|
|
|
|
dom.on('blur', () => this._onBlur(validated)),
|
|
|
|
|
...args
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected async _onBlur(validated: IValidated<string>): Promise<void> {
|
|
|
|
|
// Do not show empty input errors on blur.
|
|
|
|
|
if (validated.value.get().length === 0) { return; }
|
|
|
|
|
try {
|
|
|
|
|
await validated.get();
|
|
|
|
|
this.formError.set('');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.formError.set(e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-11-04 17:31:08 +00:00
|
|
|
|
|
|
|
|
|
protected maybeAutoFocus() {
|
|
|
|
|
if (this.shouldAutoFocus) {
|
|
|
|
|
this.shouldAutoFocus = false;
|
|
|
|
|
return (elem: HTMLElement) => { setTimeout(() => elem.focus(), 0); };
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2020-10-26 14:45:31 +00:00
|
|
|
|
private readonly _name: IValidated<string> = createValidated(this, checkRequired('Name'));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
|
|
constructor(private readonly _options: {
|
|
|
|
|
showHeader: boolean;
|
|
|
|
|
autofill?: Partial<IBillingCard>;
|
|
|
|
|
}) {
|
|
|
|
|
super();
|
|
|
|
|
const autofill = this._options.autofill;
|
2021-04-26 21:54:09 +00:00
|
|
|
|
const stripeAPIKey = G.window.gristConfig.stripeAPIKey;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
try {
|
|
|
|
|
this._stripe = G.Stripe(stripeAPIKey);
|
|
|
|
|
this._elements = this._stripe.elements();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
reportError(err);
|
|
|
|
|
}
|
|
|
|
|
if (autofill) {
|
|
|
|
|
this._name.value.set(autofill.name || '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public buildDom() {
|
|
|
|
|
return this._stripe ? css.paymentBlock(
|
|
|
|
|
this._options.showHeader ? css.paymentSubHeader('Payment Method') : null,
|
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel('Cardholder Name'),
|
|
|
|
|
this.billingInput(this._name, testId('card-name')),
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel({for: 'number-element'}, 'Card Number'),
|
|
|
|
|
css.stripeInput({id: 'number-element'}), // A Stripe Element will be inserted here.
|
|
|
|
|
testId('card-number')
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel({for: 'expiry-element'}, 'Expiry Date'),
|
|
|
|
|
css.stripeInput({id: 'expiry-element'}), // A Stripe Element will be inserted here.
|
|
|
|
|
testId('card-expiry')
|
|
|
|
|
),
|
|
|
|
|
css.paymentSpacer(),
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel({for: 'cvc-element'}, 'CVC / CVV Code'),
|
|
|
|
|
css.stripeInput({id: 'cvc-element'}), // A Stripe Element will be inserted here.
|
|
|
|
|
testId('card-cvc')
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
css.inputError(
|
|
|
|
|
dom.text(this.formError),
|
|
|
|
|
testId('payment-form-error')
|
|
|
|
|
),
|
|
|
|
|
() => { setTimeout(() => this._mountStripeUI(), 0); }
|
|
|
|
|
) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getCardAndToken(): Promise<{card: IBillingCard, token: string}> {
|
|
|
|
|
// Note that we call createToken using only the card number element as the first argument
|
|
|
|
|
// in accordance with the Stripe API:
|
|
|
|
|
//
|
|
|
|
|
// "If applicable, the Element pulls data from other Elements you’ve created on the same
|
|
|
|
|
// instance of elements to tokenize—you only need to supply one element as the parameter."
|
|
|
|
|
//
|
|
|
|
|
// Source: https://stripe.com/docs/stripe-js/reference#stripe-create-token
|
|
|
|
|
try {
|
|
|
|
|
const result = await this._stripe.createToken(this._numberElement.get(), {name: await this._name.get()});
|
|
|
|
|
if (result.error) { throw new Error(result.error.message); }
|
|
|
|
|
return {
|
|
|
|
|
card: result.token.card,
|
|
|
|
|
token: result.token.id
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.formError.set(e.message);
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _mountStripeUI() {
|
|
|
|
|
// Mount Stripe Element fields.
|
|
|
|
|
this._mountStripeElement(this._numberElement, 'cardNumber', 'number-element');
|
|
|
|
|
this._mountStripeElement(this._expiryElement, 'cardExpiry', 'expiry-element');
|
|
|
|
|
this._mountStripeElement(this._cvcElement, 'cardCvc', 'cvc-element');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _mountStripeElement(elemObs: Observable<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 {
|
2020-10-26 14:45:31 +00:00
|
|
|
|
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();
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
|
|
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 || '');
|
|
|
|
|
}
|
2020-10-26 14:45:31 +00:00
|
|
|
|
this._countryCode.value.set(autofill?.country || 'US');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;'},
|
2020-10-26 14:45:31 +00:00
|
|
|
|
dom.domComputed(this._isUS, (isUs) =>
|
|
|
|
|
isUs ? [
|
|
|
|
|
css.paymentLabel('State'),
|
|
|
|
|
cssSelect(this._state.value, states),
|
|
|
|
|
] : [
|
|
|
|
|
css.paymentLabel('State / Region'),
|
|
|
|
|
this.billingInput(this._state),
|
|
|
|
|
]
|
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
|
testId('address-state')
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
2020-10-26 14:45:31 +00:00
|
|
|
|
css.paymentLabel(dom.text((use) => use(this._isUS) ? 'Zip Code' : 'Postal Code')),
|
2020-10-02 15:10:00 +00:00
|
|
|
|
this.billingInput(this._postal, testId('address-zip'))
|
|
|
|
|
)
|
|
|
|
|
),
|
2020-10-26 14:45:31 +00:00
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel('Country'),
|
|
|
|
|
cssSelect(this._countryCode.value, this._countries),
|
|
|
|
|
testId('address-country')
|
|
|
|
|
)
|
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
|
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
|
2021-10-20 18:18:01 +00:00
|
|
|
|
public async getAddress(): Promise<IFilledBillingAddress|undefined> {
|
2020-10-02 15:10:00 +00:00
|
|
|
|
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(),
|
2020-10-26 14:45:31 +00:00
|
|
|
|
country: await this._countryCode.get(),
|
2020-10-02 15:10:00 +00:00
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.formError.set(e.message);
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates the billing settings form, including the org name and the org subdomain values.
|
|
|
|
|
*/
|
|
|
|
|
class BillingSettingsForm extends BillingSubForm {
|
2020-10-26 14:45:31 +00:00
|
|
|
|
private readonly _name: IValidated<string> = createValidated(this, checkRequired('Company name'));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
// Only verify the domain if it is shown.
|
2020-10-26 14:45:31 +00:00
|
|
|
|
private readonly _domain: IValidated<string> = createValidated(this,
|
2020-10-02 15:10:00 +00:00
|
|
|
|
this._options.showDomain ? d => this._verifyDomain(d) : () => undefined);
|
|
|
|
|
|
|
|
|
|
constructor(
|
2021-10-20 18:18:01 +00:00
|
|
|
|
private readonly _billingModel: BillingModel,
|
2020-10-02 15:10:00 +00:00
|
|
|
|
private readonly _org: Organization|null,
|
|
|
|
|
private readonly _options: {
|
|
|
|
|
showHeader: boolean;
|
|
|
|
|
showDomain: boolean;
|
|
|
|
|
autofill?: Partial<IBillingOrgSettings>;
|
|
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
super();
|
|
|
|
|
const autofill = this._options.autofill;
|
|
|
|
|
if (autofill) {
|
|
|
|
|
this._name.value.set(autofill.name || '');
|
|
|
|
|
this._domain.value.set(autofill.domain || '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public buildDom() {
|
|
|
|
|
const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access));
|
2021-11-04 17:31:08 +00:00
|
|
|
|
const initDomain = this._options.autofill?.domain;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
return css.paymentBlock(
|
|
|
|
|
this._options.showHeader ? css.paymentSubHeader('Team Site') : null,
|
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
this.billingInput(this._name,
|
|
|
|
|
dom.boolAttr('disabled', () => noEditAccess),
|
|
|
|
|
testId('settings-name')
|
|
|
|
|
),
|
|
|
|
|
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
|
|
|
|
|
testId('settings-name-info')
|
|
|
|
|
) : null
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
this._options.showDomain ? css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel('URL'),
|
|
|
|
|
this.billingInput(this._domain,
|
|
|
|
|
dom.boolAttr('disabled', () => noEditAccess),
|
|
|
|
|
testId('settings-domain')
|
|
|
|
|
),
|
|
|
|
|
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
|
|
|
|
|
testId('settings-domain-info')
|
2021-08-18 01:44:11 +00:00
|
|
|
|
) : null,
|
2021-11-04 17:31:08 +00:00
|
|
|
|
dom.maybe((use) => initDomain && use(this._domain.value) !== initDomain, () =>
|
|
|
|
|
css.paymentFieldDanger('Any saved links will need updating if the URL changes')
|
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
|
),
|
|
|
|
|
css.paymentField({style: 'flex: 0 1 0;'},
|
|
|
|
|
css.inputHintLabel('.getgrist.com')
|
|
|
|
|
)
|
|
|
|
|
) : null,
|
|
|
|
|
css.inputError(
|
|
|
|
|
dom.text(this.formError),
|
|
|
|
|
testId('settings-form-error')
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Throws if any value is invalid.
|
|
|
|
|
public async getSettings(): Promise<IBillingOrgSettings|undefined> {
|
|
|
|
|
try {
|
|
|
|
|
return {
|
|
|
|
|
name: await this._name.get(),
|
|
|
|
|
domain: await this._domain.get()
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.formError.set(e.message);
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Throws if the entered domain contains any invalid characters or is already taken.
|
|
|
|
|
private async _verifyDomain(domain: string): Promise<void> {
|
2021-08-18 01:44:11 +00:00
|
|
|
|
// OK to retain current domain.
|
|
|
|
|
if (domain === this._options.autofill?.domain) { return; }
|
2020-10-02 15:10:00 +00:00
|
|
|
|
checkSubdomainValidity(domain);
|
2021-10-20 18:18:01 +00:00
|
|
|
|
const isAvailable = await this._billingModel.isDomainAvailable(domain);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
if (!isAvailable) { throw new Error('Domain is already taken.'); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 18:18:01 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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',
|
2021-11-04 17:31:08 +00:00
|
|
|
|
dom.on('click', () => { this.shouldAutoFocus = true; this._isExpanded.set(true); }),
|
2021-10-20 18:18:01 +00:00
|
|
|
|
testId('apply-discount-code')
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
) :
|
|
|
|
|
css.paymentBlock(
|
|
|
|
|
css.paymentRow(
|
|
|
|
|
css.paymentField(
|
|
|
|
|
css.paymentLabel('Discount Code'),
|
2021-11-04 17:31:08 +00:00
|
|
|
|
this.billingInput(this._discountCode, testId('discount-code'), this.maybeAutoFocus()),
|
2021-10-20 18:18:01 +00:00
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
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) {
|
2021-11-04 17:31:08 +00:00
|
|
|
|
const message = (e as ApiError).details?.userError;
|
|
|
|
|
this.formError.set(message || 'Invalid or expired discount code.');
|
2021-10-20 18:18:01 +00:00
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-10-26 14:45:31 +00:00
|
|
|
|
function checkFunc(func: (val: string) => boolean, message: string) {
|
|
|
|
|
return (val: string) => {
|
|
|
|
|
if (!func(val)) { throw new Error(message); }
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkRequired(propertyName: string) {
|
|
|
|
|
return checkFunc(Boolean, `${propertyName} is required.`);
|
|
|
|
|
}
|
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
|
// Creates a validated object, which includes an observable and a function to check
|
|
|
|
|
// if the current observable value is valid.
|
|
|
|
|
function createValidated(
|
|
|
|
|
owner: IDisposableOwnerT<any>,
|
2021-04-26 21:54:09 +00:00
|
|
|
|
checkValidity: (value: string) => void|Promise<void>,
|
2020-10-02 15:10:00 +00:00
|
|
|
|
): IValidated<string> {
|
|
|
|
|
const value = Observable.create(owner, '');
|
|
|
|
|
const isInvalid = Observable.create<boolean>(owner, false);
|
|
|
|
|
owner.autoDispose(value.addListener(() => { isInvalid.set(false); }));
|
|
|
|
|
return {
|
|
|
|
|
value,
|
|
|
|
|
isInvalid,
|
|
|
|
|
checkValidity,
|
|
|
|
|
get: async () => {
|
|
|
|
|
const _value = value.get();
|
|
|
|
|
try {
|
|
|
|
|
await checkValidity(_value);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
isInvalid.set(true);
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
isInvalid.set(false);
|
|
|
|
|
return _value;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
2020-10-26 14:45:31 +00:00
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
`);
|