(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 {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 {checkSubdomainValidity} from 'app/common/orgNameUtils';
@ -115,11 +116,29 @@ export class BillingForm extends Disposable {
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 BillingSubForm extends Disposable {
protected readonly formError: Observable<string> = Observable.create(this, '');
protected shouldAutoFocus = false;
constructor() {
super();
@ -145,6 +164,13 @@ abstract class BillingSubForm extends Disposable {
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() {
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(
this._options.showHeader ? css.paymentSubHeader('Team Site') : null,
css.paymentRow(
@ -426,7 +452,9 @@ class BillingSettingsForm extends BillingSubForm {
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
testId('settings-domain-info')
) : 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.inputHintLabel('.getgrist.com')
@ -490,7 +518,7 @@ class BillingDiscountForm extends BillingSubForm {
css.billingTextBtn(
css.billingIcon('Settings'),
'Apply',
dom.on('click', () => this._isExpanded.set(true)),
dom.on('click', () => { this.shouldAutoFocus = true; this._isExpanded.set(true); }),
testId('apply-discount-code')
)
)
@ -499,7 +527,7 @@ class BillingDiscountForm extends BillingSubForm {
css.paymentRow(
css.paymentField(
css.paymentLabel('Discount Code'),
this.billingInput(this._discountCode, testId('discount-code')),
this.billingInput(this._discountCode, testId('discount-code'), this.maybeAutoFocus()),
)
),
css.inputError(
@ -517,7 +545,8 @@ class BillingDiscountForm extends BillingSubForm {
try {
return await this._billingModel.fetchSignupCoupon(discountCode);
} 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;
}
}

View File

@ -770,7 +770,7 @@ export class BillingPage extends Disposable {
testId('edit')
),
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.on('click', () => this._doSubmit(task)),
testId('submit')
@ -807,6 +807,8 @@ export class BillingPage extends Disposable {
if (!this.isDisposed()) {
this._isSubmitting.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> {
const resp = await this.request(`${this._url}/api/billing/domain`, {
return this.requestJson(`${this._url}/api/billing/domain`, {
method: 'POST',
body: JSON.stringify({ domain })
});
return resp.json();
}
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',
});
return resp.json();
}
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',
body: JSON.stringify({ address })
});
return resp.json();
}
public async getPlans(): Promise<IBillingPlan[]> {
const resp = await this.request(`${this._url}/api/billing/plans`, {method: 'GET'});
return resp.json();
return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'});
}
// Returns an IBillingSubscription
public async getSubscription(): Promise<IBillingSubscription> {
const resp = await this.request(`${this._url}/api/billing/subscription`, {method: 'GET'});
return resp.json();
return this.requestJson(`${this._url}/api/billing/subscription`, {method: 'GET'});
}
public async getBillingAccount(): Promise<FullBillingAccount> {
const resp = await this.request(`${this._url}/api/billing`, {method: 'GET'});
return resp.json();
return this.requestJson(`${this._url}/api/billing`, {method: 'GET'});
}
// Returns the new Stripe customerId.
@ -208,11 +202,10 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
settings: IBillingOrgSettings,
promotionCode?: string,
): Promise<OrganizationWithoutAccessInfo> {
const resp = await this.request(`${this._url}/api/billing/signup`, {
const parsed = await this.requestJson(`${this._url}/api/billing/signup`, {
method: 'POST',
body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }),
});
const parsed = await resp.json();
return parsed.data;
}