(core) Add some polish to the billing page, particularly for sign-up.

Summary:
- Change "Continue" button to "Review" (we don't charge immediately,
  first show a review screen)
- Show more informative messages for certain failures with discount
  coupons.
- Focus form elements with error, or at least the part of the form
  containing an error.
- Auto-focus discount input box when it gets toggled on.
- Show warning about URL changes only when subdomain is changed.

Test Plan: Updated tests; tested focus and changed error messages manually.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3115
This commit is contained in:
Dmitry S 2021-11-04 13:31:08 -04:00
parent 0dd4ad34f5
commit cce679d928
3 changed files with 44 additions and 20 deletions

View File

@ -4,6 +4,7 @@ import {BillingModel} from 'app/client/models/BillingModel';
import * as css from 'app/client/ui/BillingPageCss'; import * as css from 'app/client/ui/BillingPageCss';
import {colors, vars} from 'app/client/ui2018/cssVars'; import {colors, vars} from 'app/client/ui2018/cssVars';
import {IOption, select} from 'app/client/ui2018/menus'; import {IOption, select} from 'app/client/ui2018/menus';
import type {ApiError} from 'app/common/ApiError';
import {IBillingAddress, IBillingCard, IBillingCoupon, IBillingOrgSettings, import {IBillingAddress, IBillingCard, IBillingCoupon, IBillingOrgSettings,
IFilledBillingAddress} from 'app/common/BillingAPI'; IFilledBillingAddress} from 'app/common/BillingAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils'; import {checkSubdomainValidity} from 'app/common/orgNameUtils';
@ -115,11 +116,29 @@ export class BillingForm extends Disposable {
card: cardInfo ? cardInfo.card : undefined card: cardInfo ? cardInfo.card : undefined
}; };
} }
// 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();
}
}
}
} }
// Abstract class which includes helper functions for creating a form whose values are verified. // Abstract class which includes helper functions for creating a form whose values are verified.
abstract class BillingSubForm extends Disposable { abstract class BillingSubForm extends Disposable {
protected readonly formError: Observable<string> = Observable.create(this, ''); protected readonly formError: Observable<string> = Observable.create(this, '');
protected shouldAutoFocus = false;
constructor() { constructor() {
super(); super();
@ -145,6 +164,13 @@ abstract class BillingSubForm extends Disposable {
this.formError.set(e.message); this.formError.set(e.message);
} }
} }
protected maybeAutoFocus() {
if (this.shouldAutoFocus) {
this.shouldAutoFocus = false;
return (elem: HTMLElement) => { setTimeout(() => elem.focus(), 0); };
}
}
} }
/** /**
@ -402,7 +428,7 @@ class BillingSettingsForm extends BillingSubForm {
public buildDom() { public buildDom() {
const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access)); const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access));
const hasDomain = Boolean(this._options.autofill?.domain); const initDomain = this._options.autofill?.domain;
return css.paymentBlock( return css.paymentBlock(
this._options.showHeader ? css.paymentSubHeader('Team Site') : null, this._options.showHeader ? css.paymentSubHeader('Team Site') : null,
css.paymentRow( css.paymentRow(
@ -426,7 +452,9 @@ class BillingSettingsForm extends BillingSubForm {
noEditAccess ? css.paymentFieldInfo('Organization edit access is required', noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
testId('settings-domain-info') testId('settings-domain-info')
) : null, ) : null,
hasDomain ? css.paymentFieldDanger('Any saved links will need updating if the URL changes') : null, dom.maybe((use) => initDomain && use(this._domain.value) !== initDomain, () =>
css.paymentFieldDanger('Any saved links will need updating if the URL changes')
),
), ),
css.paymentField({style: 'flex: 0 1 0;'}, css.paymentField({style: 'flex: 0 1 0;'},
css.inputHintLabel('.getgrist.com') css.inputHintLabel('.getgrist.com')
@ -490,7 +518,7 @@ class BillingDiscountForm extends BillingSubForm {
css.billingTextBtn( css.billingTextBtn(
css.billingIcon('Settings'), css.billingIcon('Settings'),
'Apply', 'Apply',
dom.on('click', () => this._isExpanded.set(true)), dom.on('click', () => { this.shouldAutoFocus = true; this._isExpanded.set(true); }),
testId('apply-discount-code') testId('apply-discount-code')
) )
) )
@ -499,7 +527,7 @@ class BillingDiscountForm extends BillingSubForm {
css.paymentRow( css.paymentRow(
css.paymentField( css.paymentField(
css.paymentLabel('Discount Code'), css.paymentLabel('Discount Code'),
this.billingInput(this._discountCode, testId('discount-code')), this.billingInput(this._discountCode, testId('discount-code'), this.maybeAutoFocus()),
) )
), ),
css.inputError( css.inputError(
@ -517,7 +545,8 @@ class BillingDiscountForm extends BillingSubForm {
try { try {
return await this._billingModel.fetchSignupCoupon(discountCode); return await this._billingModel.fetchSignupCoupon(discountCode);
} catch (e) { } catch (e) {
this.formError.set('Invalid or expired discount code.'); const message = (e as ApiError).details?.userError;
this.formError.set(message || 'Invalid or expired discount code.');
throw e; throw e;
} }
} }

View File

@ -770,7 +770,7 @@ export class BillingPage extends Disposable {
testId('edit') testId('edit')
), ),
bigPrimaryButton({style: 'margin-left: 10px;'}, bigPrimaryButton({style: 'margin-left: 10px;'},
dom.text((use) => (task !== 'signUp' || use(this._showConfirmPage)) ? submitText : 'Continue'), dom.text((use) => (task !== 'signUp' || use(this._showConfirmPage)) ? submitText : 'Review'),
dom.boolAttr('disabled', this._isSubmitting), dom.boolAttr('disabled', this._isSubmitting),
dom.on('click', () => this._doSubmit(task)), dom.on('click', () => this._doSubmit(task)),
testId('submit') testId('submit')
@ -807,6 +807,8 @@ export class BillingPage extends Disposable {
if (!this.isDisposed()) { if (!this.isDisposed()) {
this._isSubmitting.set(false); this._isSubmitting.set(false);
this._showConfirmPage.set(false); this._showConfirmPage.set(false);
// Focus the first element with an error.
this._form?.focusOnError();
} }
} }
} }

View File

@ -162,42 +162,36 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
} }
public async isDomainAvailable(domain: string): Promise<boolean> { public async isDomainAvailable(domain: string): Promise<boolean> {
const resp = await this.request(`${this._url}/api/billing/domain`, { return this.requestJson(`${this._url}/api/billing/domain`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ domain }) body: JSON.stringify({ domain })
}); });
return resp.json();
} }
public async getCoupon(promotionCode: string): Promise<IBillingCoupon> { public async getCoupon(promotionCode: string): Promise<IBillingCoupon> {
const resp = await this.request(`${this._url}/api/billing/coupon/${promotionCode}`, { return this.requestJson(`${this._url}/api/billing/coupon/${promotionCode}`, {
method: 'GET', method: 'GET',
}); });
return resp.json();
} }
public async getTaxRate(address: IBillingAddress): Promise<number> { public async getTaxRate(address: IBillingAddress): Promise<number> {
const resp = await this.request(`${this._url}/api/billing/tax`, { return this.requestJson(`${this._url}/api/billing/tax`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ address }) body: JSON.stringify({ address })
}); });
return resp.json();
} }
public async getPlans(): Promise<IBillingPlan[]> { public async getPlans(): Promise<IBillingPlan[]> {
const resp = await this.request(`${this._url}/api/billing/plans`, {method: 'GET'}); return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'});
return resp.json();
} }
// Returns an IBillingSubscription // Returns an IBillingSubscription
public async getSubscription(): Promise<IBillingSubscription> { public async getSubscription(): Promise<IBillingSubscription> {
const resp = await this.request(`${this._url}/api/billing/subscription`, {method: 'GET'}); return this.requestJson(`${this._url}/api/billing/subscription`, {method: 'GET'});
return resp.json();
} }
public async getBillingAccount(): Promise<FullBillingAccount> { public async getBillingAccount(): Promise<FullBillingAccount> {
const resp = await this.request(`${this._url}/api/billing`, {method: 'GET'}); return this.requestJson(`${this._url}/api/billing`, {method: 'GET'});
return resp.json();
} }
// Returns the new Stripe customerId. // Returns the new Stripe customerId.
@ -208,11 +202,10 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
settings: IBillingOrgSettings, settings: IBillingOrgSettings,
promotionCode?: string, promotionCode?: string,
): Promise<OrganizationWithoutAccessInfo> { ): Promise<OrganizationWithoutAccessInfo> {
const resp = await this.request(`${this._url}/api/billing/signup`, { const parsed = await this.requestJson(`${this._url}/api/billing/signup`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }), body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }),
}); });
const parsed = await resp.json();
return parsed.data; return parsed.data;
} }