mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
0dd4ad34f5
commit
cce679d928
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user