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 * 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user