mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Improving billing page user experience
Summary: Improving billing page user experience. - Updated labels for canceled plan - Adding option to downgrade from team plan to free team plan - Updating default name for teamFree plan when it is not available in Stripe - Minor bug fixes Test Plan: updated tests Reviewers: cyprien Reviewed By: cyprien Subscribers: cyprien Differential Revision: https://phab.getgrist.com/D3515
This commit is contained in:
parent
c70b427483
commit
257dafe423
@ -48,6 +48,9 @@ export interface BillingModel {
|
|||||||
getCustomerPortalUrl(): string;
|
getCustomerPortalUrl(): string;
|
||||||
// Renews plan (either by opening customer portal or creating Stripe Checkout session)
|
// Renews plan (either by opening customer portal or creating Stripe Checkout session)
|
||||||
renewPlan(): string;
|
renewPlan(): string;
|
||||||
|
// Downgrades team plan. Currently downgrades only from team to team free plan, and
|
||||||
|
// only when plan is cancelled.
|
||||||
|
downgradePlan(planName: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISubscriptionModel extends IBillingSubscription {
|
export interface ISubscriptionModel extends IBillingSubscription {
|
||||||
@ -124,6 +127,10 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
|||||||
return this._billingAPI.renewPlan();
|
return this._billingAPI.renewPlan();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async downgradePlan(planName: string): Promise<void> {
|
||||||
|
await this._billingAPI.downgradePlan(planName);
|
||||||
|
}
|
||||||
|
|
||||||
public async cancelCurrentPlan() {
|
public async cancelCurrentPlan() {
|
||||||
const data = await this._billingAPI.cancelCurrentPlan();
|
const data = await this._billingAPI.cancelCurrentPlan();
|
||||||
return data;
|
return data;
|
||||||
|
@ -10,13 +10,16 @@ import { BillingPlanManagers } from 'app/client/ui/BillingPlanManagers';
|
|||||||
import {createForbiddenPage} from 'app/client/ui/errorPages';
|
import {createForbiddenPage} from 'app/client/ui/errorPages';
|
||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||||
|
import {NEW_DEAL, showTeamUpgradeConfirmation} from 'app/client/ui/ProductUpgrades';
|
||||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||||
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
|
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
|
||||||
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
import { loadingSpinner } from 'app/client/ui2018/loaders';
|
import {colors} from 'app/client/ui2018/cssVars';
|
||||||
import { NEW_DEAL, showTeamUpgradeConfirmation } from 'app/client/ui/ProductUpgrades';
|
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
|
import {BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
|
||||||
|
import {displayPlanName, TEAM_FREE_PLAN, TEAM_PLAN} from 'app/common/Features';
|
||||||
import {capitalize} from 'app/common/gutil';
|
import {capitalize} from 'app/common/gutil';
|
||||||
import {Organization} from 'app/common/UserAPI';
|
import {Organization} from 'app/common/UserAPI';
|
||||||
import {Disposable, dom, DomArg, IAttrObj, makeTestId, Observable} from 'grainjs';
|
import {Disposable, dom, DomArg, IAttrObj, makeTestId, Observable} from 'grainjs';
|
||||||
@ -212,23 +215,27 @@ export class BillingPage extends Disposable {
|
|||||||
const discountName = sub.discount && sub.discount.name;
|
const discountName = sub.discount && sub.discount.name;
|
||||||
const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
|
const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
|
||||||
const tier = discountName && discountName.includes(' Tier ');
|
const tier = discountName && discountName.includes(' Tier ');
|
||||||
const activePlanName = sub.activePlan?.nickname ?? this._appModel.planName;
|
const activePlanName = sub.activePlan?.nickname ??
|
||||||
|
displayPlanName[this._appModel.planName || ''] ?? this._appModel.planName;
|
||||||
const planName = tier ? discountName : activePlanName;
|
const planName = tier ? discountName : activePlanName;
|
||||||
const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
|
const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
|
||||||
const isPaidPlan = sub.billable;
|
const isPaidPlan = sub.billable;
|
||||||
// If subscription is cancelled, we need to create a new one using Stripe Checkout.
|
// If subscription is canceled, we need to create a new one using Stripe Checkout.
|
||||||
const canRenew = (sub.status === 'canceled' && isPaidPlan);
|
const canRenew = (sub.status === 'canceled' && isPaidPlan);
|
||||||
// We can upgrade only free team plan at this moment.
|
// We can upgrade only free team plan at this moment.
|
||||||
const canUpgrade = !canRenew && !isPaidPlan;
|
const canUpgrade = !canRenew && !isPaidPlan;
|
||||||
// And we can manage team plan that is not cancelled.
|
// And we can manage team plan that is not canceled.
|
||||||
const canManage = !canRenew && isPaidPlan;
|
const canManage = !canRenew && isPaidPlan;
|
||||||
|
const isCanceled = sub.status === 'canceled';
|
||||||
|
const wasTeam = this._appModel.planName === TEAM_PLAN && isCanceled && !validPlan;
|
||||||
return [
|
return [
|
||||||
css.summaryFeatures(
|
css.summaryFeatures(
|
||||||
validPlan && planName ? [
|
validPlan && planName ? [
|
||||||
makeSummaryFeature(['You are subscribed to the ', planName, ' plan'])
|
makeSummaryFeature(['You are subscribed to the ', planName, ' plan'])
|
||||||
] : [
|
] : [
|
||||||
makeSummaryFeature(['This team site is not in good standing'],
|
isCanceled ?
|
||||||
{ isBad: true }),
|
makeSummaryFeature(['You were subscribed to the ', planName, ' plan'], { isBad: true }) :
|
||||||
|
makeSummaryFeature(['This team site is not in good standing'], { isBad: true }),
|
||||||
],
|
],
|
||||||
// If the plan is changing, include the date the current plan ends
|
// If the plan is changing, include the date the current plan ends
|
||||||
// and the plan that will be in effect afterwards.
|
// and the plan that will be in effect afterwards.
|
||||||
@ -239,8 +246,7 @@ export class BillingPage extends Disposable {
|
|||||||
] : null,
|
] : null,
|
||||||
cancellingPlan && isPaidPlan ? [
|
cancellingPlan && isPaidPlan ? [
|
||||||
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
|
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
|
||||||
makeSummaryFeature(['On this date, your team site will become ', 'read-only',
|
makeSummaryFeature(['On this date, your team site will become read-only'])
|
||||||
' for one month, then removed'])
|
|
||||||
] : null,
|
] : null,
|
||||||
moneyPlan?.amount ? [
|
moneyPlan?.amount ? [
|
||||||
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
|
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
|
||||||
@ -269,6 +275,9 @@ export class BillingPage extends Disposable {
|
|||||||
),
|
),
|
||||||
!canManage ? null :
|
!canManage ? null :
|
||||||
makeActionLink('Manage billing', 'Settings', this._model.getCustomerPortalUrl(), testId('portal-link')),
|
makeActionLink('Manage billing', 'Settings', this._model.getCustomerPortalUrl(), testId('portal-link')),
|
||||||
|
!wasTeam ? null :
|
||||||
|
makeActionButton('Downgrade plan', 'Settings',
|
||||||
|
() => this._confirmDowngradeToTeamFree(), testId('downgrade-free-link')),
|
||||||
!canRenew ? null :
|
!canRenew ? null :
|
||||||
makeActionLink('Renew subscription', 'Settings', this._model.renewPlan(), testId('renew-link')),
|
makeActionLink('Renew subscription', 'Settings', this._model.renewPlan(), testId('renew-link')),
|
||||||
!canUpgrade ? null :
|
!canUpgrade ? null :
|
||||||
@ -295,6 +304,29 @@ export class BillingPage extends Disposable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _confirmDowngradeToTeamFree() {
|
||||||
|
confirmModal('Downgrade to Free Team Plan',
|
||||||
|
'Downgrade',
|
||||||
|
() => this._downgradeToTeamFree(),
|
||||||
|
dom('div', {style: `color: ${colors.dark}`}, testId('downgrade-confirm-modal'),
|
||||||
|
dom('div', 'Documents on free team plan are subject to the following limits. '
|
||||||
|
+'Any documents in excess of these limits will be put in read-only mode.'),
|
||||||
|
dom('ul',
|
||||||
|
dom('li', { style: 'margin-bottom: 0.6em'}, dom('strong', '5,000'), ' rows per document'),
|
||||||
|
dom('li', { style: 'margin-bottom: 0.6em'}, dom('strong', '10MB'), ' max document size'),
|
||||||
|
dom('li', 'API limit: ', dom('strong', '5k'), ' calls/day'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _downgradeToTeamFree() {
|
||||||
|
// Perform the downgrade operation.
|
||||||
|
await this._model.downgradePlan(TEAM_FREE_PLAN);
|
||||||
|
// Refresh app model
|
||||||
|
this._appModel.topAppModel.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
private _makeAppSumoFeature(name: string) {
|
private _makeAppSumoFeature(name: string) {
|
||||||
// TODO: move AppSumo plan knowledge elsewhere.
|
// TODO: move AppSumo plan knowledge elsewhere.
|
||||||
let users = 0;
|
let users = 0;
|
||||||
|
@ -136,6 +136,7 @@ export interface BillingAPI {
|
|||||||
createTeam(name: string, domain: string): Promise<string>;
|
createTeam(name: string, domain: string): Promise<string>;
|
||||||
upgrade(): Promise<string>;
|
upgrade(): Promise<string>;
|
||||||
cancelCurrentPlan(): Promise<void>;
|
cancelCurrentPlan(): Promise<void>;
|
||||||
|
downgradePlan(planName: string): Promise<void>;
|
||||||
renewPlan(): string;
|
renewPlan(): string;
|
||||||
customerPortal(): string;
|
customerPortal(): string;
|
||||||
}
|
}
|
||||||
@ -170,6 +171,12 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async downgradePlan(planName: string): Promise<void> {
|
||||||
|
await this.request(`${this._url}/api/billing/downgrade-plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ planName })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
|
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
|
||||||
await this.request(`${this._url}/api/billing/settings`, {
|
await this.request(`${this._url}/api/billing/settings`, {
|
||||||
|
@ -68,11 +68,15 @@ export function canAddOrgMembers(features: Features): boolean {
|
|||||||
return features.maxWorkspacesPerOrg !== 1;
|
return features.maxWorkspacesPerOrg !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const FREE_PERSONAL_PLAN = 'starter';
|
export const FREE_PERSONAL_PLAN = 'starter';
|
||||||
export const TEAM_FREE_PLAN = 'teamFree';
|
export const TEAM_FREE_PLAN = 'teamFree';
|
||||||
export const TEAM_PLAN = 'team';
|
export const TEAM_PLAN = 'team';
|
||||||
|
|
||||||
|
export const displayPlanName: { [key: string]: string } = {
|
||||||
|
[TEAM_FREE_PLAN]: 'Team Free',
|
||||||
|
[TEAM_PLAN]: 'Team'
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Returns true if `product` is free.
|
// Returns true if `product` is free.
|
||||||
export function isFreeProduct(product: Product): boolean {
|
export function isFreeProduct(product: Product): boolean {
|
||||||
return [FREE_PERSONAL_PLAN, TEAM_FREE_PLAN, 'Free'].includes(product?.name);
|
return [FREE_PERSONAL_PLAN, TEAM_FREE_PLAN, 'Free'].includes(product?.name);
|
||||||
|
Loading…
Reference in New Issue
Block a user