diff --git a/app/client/ui/BillingForm.ts b/app/client/ui/BillingForm.ts index 39de2cb9..e4b84fc3 100644 --- a/app/client/ui/BillingForm.ts +++ b/app/client/ui/BillingForm.ts @@ -2,17 +2,16 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {reportError} from 'app/client/models/AppModel'; import * as css from 'app/client/ui/BillingPageCss'; import {colors, vars} from 'app/client/ui2018/cssVars'; -import {formSelect} from 'app/client/ui2018/menus'; +import {IOption, select} from 'app/client/ui2018/menus'; import {IBillingAddress, IBillingCard, IBillingOrgSettings} from 'app/common/BillingAPI'; import {checkSubdomainValidity} from 'app/common/orgNameUtils'; import * as roles from 'app/common/roles'; import {Organization} from 'app/common/UserAPI'; -import {Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable} from 'grainjs'; +import {Computed, Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable, styled} from 'grainjs'; +import sortBy = require('lodash/sortBy'); const G = getBrowserGlobals('Stripe', 'window'); const testId = makeTestId('test-bp-'); -// TODO: When countries other than the US are supported, the state entry must not be limited -// by a dropdown. 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', @@ -146,7 +145,7 @@ class BillingPaymentForm extends BillingSubForm { private readonly _numberElement: Observable = Observable.create(this, null); private readonly _expiryElement: Observable = Observable.create(this, null); private readonly _cvcElement: Observable = Observable.create(this, null); - private readonly _name: IValidated = createValidated(this, 'Name'); + private readonly _name: IValidated = createValidated(this, checkRequired('Name')); constructor(private readonly _options: { showHeader: boolean; @@ -259,11 +258,18 @@ class BillingPaymentForm extends BillingSubForm { * Creates the company address entry form. Used by BillingPaymentForm when billing address is needed. */ class BillingAddressForm extends BillingSubForm { - private readonly _address1: IValidated = createValidated(this, 'Address'); - private readonly _address2: IValidated = createValidated(this, 'Suite/unit', () => undefined); - private readonly _city: IValidated = createValidated(this, 'City'); - private readonly _state: IValidated = createValidated(this, 'State'); - private readonly _postal: IValidated = createValidated(this, 'Zip code'); + private readonly _address1: IValidated = createValidated(this, checkRequired('Address')); + private readonly _address2: IValidated = createValidated(this, () => undefined); + private readonly _city: IValidated = createValidated(this, checkRequired('City')); + private readonly _state: IValidated = createValidated(this, checkFunc( + (val) => !this._isUS.get() || Boolean(val), `State is required.`)); + private readonly _postal: IValidated = createValidated(this, checkFunc( + (val) => !this._isUS.get() || Boolean(val), 'Zip code is required.')); + private readonly _countryCode: IValidated = createValidated(this, checkRequired('Country')); + + private _isUS = Computed.create(this, this._countryCode.value, (use, code) => (code === 'US')); + + private readonly _countries: Array> = getCountries(); constructor(private readonly _options: { showHeader: boolean; @@ -278,6 +284,7 @@ class BillingAddressForm extends BillingSubForm { this._state.value.set(autofill.state || ''); this._postal.value.set(autofill.postal_code || ''); } + this._countryCode.value.set(autofill?.country || 'US'); } public buildDom() { @@ -302,17 +309,31 @@ class BillingAddressForm extends BillingSubForm { ), css.paymentSpacer(), css.paymentField({style: 'flex: 0.5 1 0;'}, - css.paymentLabel('State'), - formSelect(this._state.value, states), + 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('Zip Code'), + 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') @@ -331,7 +352,7 @@ class BillingAddressForm extends BillingSubForm { city: await this._city.get(), state: await this._state.get(), postal_code: await this._postal.get(), - country: 'US' // TODO: Support more countries. + country: await this._countryCode.get(), }; } catch (e) { this.formError.set(e.message); @@ -344,9 +365,9 @@ class BillingAddressForm extends BillingSubForm { * Creates the billing settings form, including the org name and the org subdomain values. */ class BillingSettingsForm extends BillingSubForm { - private readonly _name: IValidated = createValidated(this, 'Company name'); + private readonly _name: IValidated = createValidated(this, checkRequired('Company name')); // Only verify the domain if it is shown. - private readonly _domain: IValidated = createValidated(this, 'URL', + private readonly _domain: IValidated = createValidated(this, this._options.showDomain ? d => this._verifyDomain(d) : () => undefined); constructor( @@ -427,16 +448,22 @@ class BillingSettingsForm extends BillingSubForm { } } +function checkFunc(func: (val: string) => boolean, message: string) { + return (val: string) => { + if (!func(val)) { throw new Error(message); } + }; +} + +function checkRequired(propertyName: string) { + return checkFunc(Boolean, `${propertyName} is required.`); +} + // Creates a validated object, which includes an observable and a function to check // if the current observable value is valid. function createValidated( owner: IDisposableOwnerT, - propertyName: string, - validationFn?: (value: string) => void + checkValidity: (value: string) => void ): IValidated { - const checkValidity = validationFn || ((_value: string) => { - if (!_value) { throw new Error(`${propertyName} is required.`); } - }); const value = Observable.create(owner, ''); const isInvalid = Observable.create(owner, false); owner.autoDispose(value.addListener(() => { isInvalid.set(false); })); @@ -457,3 +484,23 @@ function createValidated( } }; } + +function getCountries(): Array> { + // Require just the one file because it has all the data we need and is substantially smaller + // than requiring the whole module. + const countryNames = require("i18n-iso-countries/langs/en.json").countries; + const codes = Object.keys(countryNames); + const entries = codes.map(code => { + // The module provides names that are either a string or an array of names. If an array, pick + // the first one. + const names = countryNames[code]; + return {value: code, label: Array.isArray(names) ? names[0] : names}; + }); + return sortBy(entries, 'label'); +} + +const cssSelect = styled(select, ` + height: 42px; + padding-left: 13px; + align-items: center; +`); diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index 203be19d..b7d169bb 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -491,10 +491,16 @@ export class BillingPage extends Disposable { testId('company-address-2') ) ) : null, - address.city && address.state ? css.summaryRow( - css.billingText(`${address.city}, ${address.state} ${address.postal_code || ''}`, + css.summaryRow( + css.billingText(formatCityStateZip(address), testId('company-address-3') ) + ), + address.country ? css.summaryRow( + // This show a 2-letter country code (e.g. "US" or "DE"). This seems fine. + css.billingText(address.country, + testId('company-address-country') + ) ) : null, ] : 'Fetching address...' ); @@ -781,3 +787,8 @@ function timeFmt(timestamp: number): string { return new Date(timestamp).toLocaleString('default', {month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric'}); } + +function formatCityStateZip(address: Partial) { + const cityState = [address.city, address.state].filter(Boolean).join(', '); + return [cityState, address.postal_code].filter(Boolean).join(' '); +} diff --git a/package.json b/package.json index e241c54f..3da9c105 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "fs-extra": "7.0.0", "grain-rpc": "0.1.6", "grainjs": "1.0.1", + "i18n-iso-countries": "6.1.0", "image-size": "0.6.3", "jquery": "2.2.1", "js-yaml": "3.12.0",