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 "./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; | ||||
|  | ||||
| @ -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 { 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); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user