From cce679d928b38141e4118694a8db06338aed1078 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Thu, 4 Nov 2021 13:31:08 -0400 Subject: [PATCH] (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 --- app/client/ui/BillingForm.ts | 39 +++++++++++++++++++++++++++++++----- app/client/ui/BillingPage.ts | 4 +++- app/common/BillingAPI.ts | 21 +++++++------------ 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/app/client/ui/BillingForm.ts b/app/client/ui/BillingForm.ts index 5d86f50b..344e54ce 100644 --- a/app/client/ui/BillingForm.ts +++ b/app/client/ui/BillingForm.ts @@ -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 = 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; } } diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index fb7e0ac5..4705d6ab 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -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(); } } } diff --git a/app/common/BillingAPI.ts b/app/common/BillingAPI.ts index 9c1c9ef0..efbc745e 100644 --- a/app/common/BillingAPI.ts +++ b/app/common/BillingAPI.ts @@ -162,42 +162,36 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI { } public async isDomainAvailable(domain: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; }