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:
parent
c772ee2362
commit
42b4768b59
@ -38,7 +38,6 @@ import "./browser-profiles-list";
|
|||||||
import "./browser-profiles-new";
|
import "./browser-profiles-new";
|
||||||
import "./settings/settings";
|
import "./settings/settings";
|
||||||
import "./dashboard";
|
import "./dashboard";
|
||||||
import "./payment-portal-redirect";
|
|
||||||
|
|
||||||
const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"];
|
const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"];
|
||||||
type ResourceName = (typeof RESOURCE_NAMES)[number];
|
type ResourceName = (typeof RESOURCE_NAMES)[number];
|
||||||
@ -77,7 +76,6 @@ export type OrgParams = {
|
|||||||
settings: {
|
settings: {
|
||||||
settingsTab?: "information" | "members";
|
settingsTab?: "information" | "members";
|
||||||
};
|
};
|
||||||
"payment-portal-redirect": {};
|
|
||||||
};
|
};
|
||||||
export type OrgTab = keyof OrgParams;
|
export type OrgTab = keyof OrgParams;
|
||||||
|
|
||||||
@ -310,9 +308,6 @@ export class Org extends LiteElement {
|
|||||||
}
|
}
|
||||||
// falls through
|
// falls through
|
||||||
}
|
}
|
||||||
case "payment-portal-redirect":
|
|
||||||
tabPanelContent = this.renderPaymentPortalRedirect();
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
tabPanelContent = html`<btrix-not-found
|
tabPanelContent = html`<btrix-not-found
|
||||||
class="flex items-center justify-center"
|
class="flex items-center justify-center"
|
||||||
@ -726,14 +721,6 @@ export class Org extends LiteElement {
|
|||||||
></btrix-org-settings>`;
|
></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) {
|
private async onSelectNewDialog(e: SelectNewDialogEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.isCreateDialogVisible = true;
|
this.isCreateDialogVisible = true;
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +1,18 @@
|
|||||||
import { localized, msg, str } from "@lit/localize";
|
import { localized, msg, str } from "@lit/localize";
|
||||||
|
import { Task } from "@lit/task";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { css, html, nothing } from "lit";
|
import { css, html, nothing } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
import { when } from "lit/directives/when.js";
|
import { when } from "lit/directives/when.js";
|
||||||
|
|
||||||
import { columns } from "../ui/columns";
|
import { columns } from "../ui/columns";
|
||||||
|
|
||||||
import { TailwindElement } from "@/classes/TailwindElement";
|
import { TailwindElement } from "@/classes/TailwindElement";
|
||||||
import { NavigateController } from "@/controllers/navigate";
|
import { APIController } from "@/controllers/api";
|
||||||
import { SubscriptionStatus } from "@/types/billing";
|
import { SubscriptionStatus, type BillingPortal } from "@/types/billing";
|
||||||
import type { OrgData, OrgQuotas } from "@/types/org";
|
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 { humanizeSeconds } from "@/utils/executionTimeFormatter";
|
||||||
import { formatNumber, getLocale } from "@/utils/localization";
|
import { formatNumber, getLocale } from "@/utils/localization";
|
||||||
import { pluralOf } from "@/utils/pluralize";
|
import { pluralOf } from "@/utils/pluralize";
|
||||||
@ -43,14 +45,14 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
@property({ type: String, noAccessor: true })
|
@property({ type: String, noAccessor: true })
|
||||||
salesEmail?: string;
|
salesEmail?: string;
|
||||||
|
|
||||||
private readonly navigate = new NavigateController(this);
|
private readonly api = new APIController(this);
|
||||||
|
|
||||||
get portalUrlLabel() {
|
get portalUrlLabel() {
|
||||||
const subscription = this.org?.subscription;
|
const subscription = this.org?.subscription;
|
||||||
|
|
||||||
if (!subscription) return;
|
if (!subscription) return;
|
||||||
|
|
||||||
let label = msg("Manage Plan");
|
let label = msg("Manage Billing");
|
||||||
|
|
||||||
switch (subscription.status) {
|
switch (subscription.status) {
|
||||||
case SubscriptionStatus.PausedPaymentFailed: {
|
case SubscriptionStatus.PausedPaymentFailed: {
|
||||||
@ -68,6 +70,28 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
return label;
|
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() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="rounded-lg border">
|
<div class="rounded-lg border">
|
||||||
@ -95,6 +119,32 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
? this.renderContactSalesLink(this.salesEmail)
|
? this.renderContactSalesLink(this.salesEmail)
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</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)}
|
${this.renderQuotas(org.quotas)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
@ -157,41 +207,20 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
|
|
||||||
switch (subscription.status) {
|
switch (subscription.status) {
|
||||||
case SubscriptionStatus.Active: {
|
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`
|
statusLabel = html`
|
||||||
<span class="text-success-700">${msg("Active")}</span>
|
<span class="text-success-700">${msg("Active")}</span>
|
||||||
`;
|
`;
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SubscriptionStatus.PausedPaymentFailed: {
|
case SubscriptionStatus.PausedPaymentFailed: {
|
||||||
statusLabel = html`
|
statusLabel = html`
|
||||||
<span class="text-warning-600">
|
<span class="text-danger">${msg("Paused, payment failed")}</span>
|
||||||
${msg("Paused due to failed payment")}
|
|
||||||
</span>
|
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SubscriptionStatus.Cancelled: {
|
case SubscriptionStatus.Cancelled: {
|
||||||
statusLabel = html`
|
statusLabel = html`
|
||||||
<span class="text-danger-700">${msg("Canceled")}</span>
|
<span class="text-danger">${msg("Canceled")}</span>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -235,7 +264,7 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
${msg(
|
${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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -245,8 +274,22 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
return html`
|
return html`
|
||||||
<a
|
<a
|
||||||
class=${manageLinkClasslist}
|
class=${manageLinkClasslist}
|
||||||
href=${`${this.navigate.orgBasePath}/payment-portal-redirect`}
|
href=${ifDefined(this.portalUrl.value)}
|
||||||
target="btrixPaymentTab"
|
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}
|
${this.portalUrlLabel}
|
||||||
<sl-icon name="arrow-right"></sl-icon>
|
<sl-icon name="arrow-right"></sl-icon>
|
||||||
@ -266,4 +309,8 @@ export class OrgSettingsBilling extends TailwindElement {
|
|||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getPortalUrl(orgId: string, auth: Auth) {
|
||||||
|
return this.api.fetch<BillingPortal>(`/orgs/${orgId}/billing-portal`, auth);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,6 @@ export const ROUTES = {
|
|||||||
"(/collections(/new)(/view/:collectionId(/:collectionTab)))",
|
"(/collections(/new)(/view/:collectionId(/:collectionTab)))",
|
||||||
"(/browser-profiles(/profile(/browser/:browserId)(/:browserProfileId)))",
|
"(/browser-profiles(/profile(/browser/:browserId)(/:browserProfileId)))",
|
||||||
"(/settings(/:settingsTab))",
|
"(/settings(/:settingsTab))",
|
||||||
"(/payment-portal-redirect)",
|
|
||||||
].join(""),
|
].join(""),
|
||||||
users: "/users",
|
users: "/users",
|
||||||
usersInvite: "/users/invite",
|
usersInvite: "/users/invite",
|
||||||
|
Loading…
Reference in New Issue
Block a user