feat: Billing UI fast-follows (#1936)

### Changes
- Updates customer portal link label
- Opens billing portal in the same tab
- Shows separate cancel date
- Makes payment failed appear as error
- Fixes crawl time quota
This commit is contained in:
sua yoo 2024-07-17 17:13:28 -07:00 committed by GitHub
parent c772ee2362
commit 42b4768b59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 81 additions and 135 deletions

View File

@ -38,7 +38,6 @@ import "./browser-profiles-list";
import "./browser-profiles-new";
import "./settings/settings";
import "./dashboard";
import "./payment-portal-redirect";
const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"];
type ResourceName = (typeof RESOURCE_NAMES)[number];
@ -77,7 +76,6 @@ export type OrgParams = {
settings: {
settingsTab?: "information" | "members";
};
"payment-portal-redirect": {};
};
export type OrgTab = keyof OrgParams;
@ -310,9 +308,6 @@ export class Org extends LiteElement {
}
// falls through
}
case "payment-portal-redirect":
tabPanelContent = this.renderPaymentPortalRedirect();
break;
default:
tabPanelContent = html`<btrix-not-found
class="flex items-center justify-center"
@ -726,14 +721,6 @@ export class Org extends LiteElement {
></btrix-org-settings>`;
}
private renderPaymentPortalRedirect() {
return html`<btrix-org-payment-portal-redirect
class="flex flex-1"
orgId=${this.orgId}
.authState=${this.authState}
></btrix-org-payment-portal-redirect>`;
}
private async onSelectNewDialog(e: SelectNewDialogEvent) {
e.stopPropagation();
this.isCreateDialogVisible = true;

View File

@ -1,87 +0,0 @@
import { localized, msg } from "@lit/localize";
import { Task } from "@lit/task";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { TailwindElement } from "@/classes/TailwindElement";
import { APIController } from "@/controllers/api";
import { NavigateController } from "@/controllers/navigate";
import type { BillingPortal } from "@/types/billing";
import type { Auth, AuthState } from "@/utils/AuthService";
@localized()
@customElement("btrix-org-payment-portal-redirect")
export class OrgPaymentPortalRedirect extends TailwindElement {
@property({ type: Object })
authState?: AuthState;
@property({ type: String })
orgId?: string;
private readonly api = new APIController(this);
private readonly navigate = new NavigateController(this);
private readonly portalUrl = new Task(this, {
task: async ([orgId, authState]) => {
if (!orgId || !authState) throw new Error("Missing args");
try {
const { portalUrl } = await this.getPortalUrl(orgId, authState);
if (portalUrl) {
window.location.href = portalUrl;
} else {
throw new Error("Missing portalUrl");
}
} catch (e) {
console.debug(e);
throw new Error(
msg("Sorry, couldn't retrieve current plan at this time."),
);
}
},
args: () => [this.orgId, this.authState] as const,
});
render() {
return html`<div
class="flex flex-1 flex-col items-center justify-center gap-4 pb-12"
>
${this.portalUrl.render({
pending: () => html`
<sl-spinner class="text-2xl"></sl-spinner>
<p class="text-neutral-500">
${msg("Redirecting to billing portal...")}
</p>
`,
error: () => html`
<sl-icon
name="exclamation-triangle-fill"
class="text-2xl text-danger-400"
></sl-icon>
<p class="text-neutral-500">
${msg("Sorry, the billing portal is unavailable at this time.")}
</p>
<sl-button
size="small"
@click=${() => {
if (window.opener) {
window.opener.focus();
window.close();
} else {
this.navigate.to(
`${this.navigate.orgBasePath}/settings/billing`,
);
}
}}
>${msg("Back to Org Settings")}</sl-button
>
`,
})}
</div>`;
}
private async getPortalUrl(orgId: string, auth: Auth) {
return this.api.fetch<BillingPortal>(`/orgs/${orgId}/billing-portal`, auth);
}
}

View File

@ -1,16 +1,18 @@
import { localized, msg, str } from "@lit/localize";
import { Task } from "@lit/task";
import clsx from "clsx";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js";
import { columns } from "../ui/columns";
import { TailwindElement } from "@/classes/TailwindElement";
import { NavigateController } from "@/controllers/navigate";
import { SubscriptionStatus } from "@/types/billing";
import { APIController } from "@/controllers/api";
import { SubscriptionStatus, type BillingPortal } from "@/types/billing";
import type { OrgData, OrgQuotas } from "@/types/org";
import type { AuthState } from "@/utils/AuthService";
import type { Auth, AuthState } from "@/utils/AuthService";
import { humanizeSeconds } from "@/utils/executionTimeFormatter";
import { formatNumber, getLocale } from "@/utils/localization";
import { pluralOf } from "@/utils/pluralize";
@ -43,14 +45,14 @@ export class OrgSettingsBilling extends TailwindElement {
@property({ type: String, noAccessor: true })
salesEmail?: string;
private readonly navigate = new NavigateController(this);
private readonly api = new APIController(this);
get portalUrlLabel() {
const subscription = this.org?.subscription;
if (!subscription) return;
let label = msg("Manage Plan");
let label = msg("Manage Billing");
switch (subscription.status) {
case SubscriptionStatus.PausedPaymentFailed: {
@ -68,6 +70,28 @@ export class OrgSettingsBilling extends TailwindElement {
return label;
}
private readonly portalUrl = new Task(this, {
task: async ([org, authState]) => {
if (!org || !authState) throw new Error("Missing args");
try {
const { portalUrl } = await this.getPortalUrl(org.id, authState);
if (portalUrl) {
return portalUrl;
} else {
throw new Error("Missing portalUrl");
}
} catch (e) {
console.debug(e);
throw new Error(
msg("Sorry, couldn't retrieve current plan at this time."),
);
}
},
args: () => [this.org, this.authState] as const,
});
render() {
return html`
<div class="rounded-lg border">
@ -95,6 +119,32 @@ export class OrgSettingsBilling extends TailwindElement {
? this.renderContactSalesLink(this.salesEmail)
: nothing}
</div>
${org.subscription?.futureCancelDate
? html`
<div
class="mb-3 flex items-center gap-2 border-b pb-3 text-neutral-500"
>
<sl-icon
name="info-circle"
class="text-base"
></sl-icon>
<span>
${msg(
html`Your plan will be canceled on
<sl-format-date
lang=${getLocale()}
class="truncate"
date=${org.subscription.futureCancelDate}
month="long"
day="numeric"
year="numeric"
>
</sl-format-date>`,
)}
</span>
</div>
`
: nothing}
${this.renderQuotas(org.quotas)}
`,
)}
@ -157,41 +207,20 @@ export class OrgSettingsBilling extends TailwindElement {
switch (subscription.status) {
case SubscriptionStatus.Active: {
if (subscription.futureCancelDate) {
statusLabel = html`
<span class="text-danger"
>${msg(
html`Canceling on
<sl-format-date
lang=${getLocale()}
class="truncate"
date=${subscription.futureCancelDate}
month="2-digit"
day="2-digit"
year="2-digit"
>
</sl-format-date>`,
)}</span
>
`;
} else {
statusLabel = html`
<span class="text-success-700">${msg("Active")}</span>
`;
}
statusLabel = html`
<span class="text-success-700">${msg("Active")}</span>
`;
break;
}
case SubscriptionStatus.PausedPaymentFailed: {
statusLabel = html`
<span class="text-warning-600">
${msg("Paused due to failed payment")}
</span>
<span class="text-danger">${msg("Paused, payment failed")}</span>
`;
break;
}
case SubscriptionStatus.Cancelled: {
statusLabel = html`
<span class="text-danger-700">${msg("Canceled")}</span>
<span class="text-danger">${msg("Canceled")}</span>
`;
break;
}
@ -235,7 +264,7 @@ export class OrgSettingsBilling extends TailwindElement {
</li>
<li>
${msg(
str`${quotas.maxExecMinutesPerMonth ? humanizeSeconds(quotas.maxExecMinutesPerMonth, undefined, undefined, "long") : msg("Unlimited minutes")} of base crawling time per month`,
str`${quotas.maxExecMinutesPerMonth ? humanizeSeconds(quotas.maxExecMinutesPerMonth * 60, undefined, undefined, "long") : msg("Unlimited minutes")} of base crawling time per month`,
)}
</li>
</ul>
@ -245,8 +274,22 @@ export class OrgSettingsBilling extends TailwindElement {
return html`
<a
class=${manageLinkClasslist}
href=${`${this.navigate.orgBasePath}/payment-portal-redirect`}
target="btrixPaymentTab"
href=${ifDefined(this.portalUrl.value)}
rel="noreferrer noopener"
@click=${async (e: MouseEvent) => {
e.preventDefault();
// Navigate to freshest portal URL
try {
await this.portalUrl.run();
if (this.portalUrl.value) {
window.location.href = this.portalUrl.value;
}
} catch (e) {
console.debug(e);
}
}}
>
${this.portalUrlLabel}
<sl-icon name="arrow-right"></sl-icon>
@ -266,4 +309,8 @@ export class OrgSettingsBilling extends TailwindElement {
</a>
`;
}
private async getPortalUrl(orgId: string, auth: Auth) {
return this.api.fetch<BillingPortal>(`/orgs/${orgId}/billing-portal`, auth);
}
}

View File

@ -18,7 +18,6 @@ export const ROUTES = {
"(/collections(/new)(/view/:collectionId(/:collectionTab)))",
"(/browser-profiles(/profile(/browser/:browserId)(/:browserProfileId)))",
"(/settings(/:settingsTab))",
"(/payment-portal-redirect)",
].join(""),
users: "/users",
usersInvite: "/users/invite",