diff --git a/apps/web/src/page/onboarding/bill-through-link.ts b/apps/web/src/page/onboarding/bill-through-link.ts new file mode 100644 index 0000000000..48bee0e9c1 --- /dev/null +++ b/apps/web/src/page/onboarding/bill-through-link.ts @@ -0,0 +1,18 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { AppRoute, hrefOf } from "../../route" + +/** Under the plan picker: an org another org pays for links itself in billing settings instead. */ +export const billThroughAnotherOrgLink = (h: HtmlBuilder): Html => + h.p( + [h.Class("text-center text-xs text-muted-foreground")], + [ + "Billed through another organization? ", + h.a( + [ + h.Href(hrefOf(AppRoute.Settings({ search: { tab: "billing" } }))), + h.Class("text-foreground underline-offset-4 hover:underline"), + ], + ["Link it in billing settings"], + ), + ], + ) diff --git a/apps/web/src/page/onboarding/plan-gate.test.ts b/apps/web/src/page/onboarding/plan-gate.test.ts index 7323241312..7d2107ac29 100644 --- a/apps/web/src/page/onboarding/plan-gate.test.ts +++ b/apps/web/src/page/onboarding/plan-gate.test.ts @@ -41,15 +41,32 @@ describe("plan gate", () => { test("an org that never held a plan is sent to onboarding with its destination", () => { expect(gate(home, customerWith(null)).redirect).toEqual( - Option.some({ url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), isReplace: true }), + Option.some({ + url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), + isReplace: true, + }), ) }) + test("an org with no plan reaches billing settings, where it links to the org that pays", () => { + const billing = AppRoute.Settings({ search: { tab: "billing" } }) + expect(gate(billing, customerWith(null)).redirect).toEqual(Option.none()) + expect(gate(billing, loading).isSplash).toBe(false) + expect( + Option.isSome( + gate(AppRoute.Settings({ search: { tab: "members" } }), customerWith(null)).redirect, + ), + ).toBe(true) + }) + test("a subscriber finishes the onboarding steps after the plan, and leaves the plan picker", () => { - expect(gate(AppRoute.QuickStart({ search: {} }), customerWith("active")).redirect).toEqual(Option.none()) - expect(gate(AppRoute.SelectPlan({ search: { redirect_url: "/traces" } }), customerWith("active")).redirect).toEqual( - Option.some({ url: "/traces", isReplace: true }), + expect(gate(AppRoute.QuickStart({ search: {} }), customerWith("active")).redirect).toEqual( + Option.none(), ) + expect( + gate(AppRoute.SelectPlan({ search: { redirect_url: "/traces" } }), customerWith("active")) + .redirect, + ).toEqual(Option.some({ url: "/traces", isReplace: true })) }) test("a failed customer read fails open", () => { @@ -78,7 +95,10 @@ describe("plan gate", () => { expect(at(elsewhere, AppRoute.CliLogin({ search: {} })).wrongRegion).toBe("eu") const open: ClerkOrganization = { ...elsewhere, isRegionOpen: true } expect(at(open, home).redirect).toEqual( - Option.some({ url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), isReplace: true }), + Option.some({ + url: hrefOf(AppRoute.QuickStart({ search: { redirect_url: "/" } })), + isReplace: true, + }), ) expect(at(open, home).wrongRegion).toBeNull() }) diff --git a/apps/web/src/page/onboarding/plan-gate.ts b/apps/web/src/page/onboarding/plan-gate.ts index 09eb916946..45ef1ea7bf 100644 --- a/apps/web/src/page/onboarding/plan-gate.ts +++ b/apps/web/src/page/onboarding/plan-gate.ts @@ -67,7 +67,11 @@ export const saveSelectedPlanKnown = (orgId: string, mayRenderApp: boolean): Eff // GATE /** Routes that render their own onboarding or billing UI: never sent away for want of a plan. */ -const isAllowedWithoutPlan = AppRoute.isAnyOf(["SelectPlan", "QuickStart", "CliLogin", "McpAuthorize"]) +const isPlanFreeRoute = AppRoute.isAnyOf(["SelectPlan", "QuickStart", "CliLogin", "McpAuthorize"]) + +// Billing settings too: an org with no plan links itself to the org that pays for it there. +const isAllowedWithoutPlan = (route: AppRoute): boolean => + isPlanFreeRoute(route) || (route._tag === "Settings" && route.search.tab === "billing") /** Public paths that never read org data (`isOrgIndependentPath`). */ const isOrgIndependent = AppRoute.isAnyOf(["Unsubscribe"]) diff --git a/apps/web/src/page/onboarding/quick-start-team-view.ts b/apps/web/src/page/onboarding/quick-start-team-view.ts index dc2962d9e0..a5863c189f 100644 --- a/apps/web/src/page/onboarding/quick-start-team-view.ts +++ b/apps/web/src/page/onboarding/quick-start-team-view.ts @@ -10,6 +10,7 @@ import { skeleton } from "../../ui/skeleton" import { icon, type IconName } from "../../ui/icon" import { input } from "../../ui/input" import { label } from "../../ui/label" +import { billThroughAnotherOrgLink } from "./bill-through-link" import { Message } from "./quick-start" import { STEP_BODY_CLASS, STEP_FOOTER_CLASS } from "./step-layout" import { isAlreadyInvited, type Team, TeamMessage } from "./quick-start-team" @@ -299,6 +300,7 @@ export const planStep = (h: H, isCustomerLoading: boolean, pricing: Html): Html ], ), isCustomerLoading ? pricingSkeleton(h) : pricing, + h.div([h.Class("mt-8")], [billThroughAnotherOrgLink(h)]), ], ), ], diff --git a/apps/web/src/page/onboarding/select-plan.ts b/apps/web/src/page/onboarding/select-plan.ts index 37a681013a..8a641d3d60 100644 --- a/apps/web/src/page/onboarding/select-plan.ts +++ b/apps/web/src/page/onboarding/select-plan.ts @@ -8,6 +8,7 @@ import { defineMessageUnion } from "foldkit/message" import type { Resources } from "../../platform/resources" import { badge } from "../../ui/display" import { icon } from "../../ui/icon" +import { billThroughAnotherOrgLink } from "./bill-through-link" import * as PricingCards from "./pricing-cards" import * as PricingCardsView from "./pricing-cards-view" @@ -142,6 +143,7 @@ export const view = (h: H, model: Model, customer: BillingCustomer | undefined): }), ], ), + billThroughAnotherOrgLink(h), ], ), ],