From aaf5045dd393275f08860bded889016154f5d913 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Mar 2026 17:16:03 +0000 Subject: [PATCH 01/20] =?UTF-8?q?Makes=20LogsSearchInput=20more=20generic?= =?UTF-8?q?=20as=20it=E2=80=99s=20being=20used=20in=20multiple=20places?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchInput.tsx} | 28 ++++++----- .../route.tsx | 48 +++++-------------- .../route.tsx | 6 +-- .../route.tsx | 6 +-- 4 files changed, 34 insertions(+), 54 deletions(-) rename apps/webapp/app/components/{logs/LogsSearchInput.tsx => primitives/SearchInput.tsx} (78%) diff --git a/apps/webapp/app/components/logs/LogsSearchInput.tsx b/apps/webapp/app/components/primitives/SearchInput.tsx similarity index 78% rename from apps/webapp/app/components/logs/LogsSearchInput.tsx rename to apps/webapp/app/components/primitives/SearchInput.tsx index 44f4d130185..ea9156839a3 100644 --- a/apps/webapp/app/components/logs/LogsSearchInput.tsx +++ b/apps/webapp/app/components/primitives/SearchInput.tsx @@ -6,22 +6,25 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { useSearchParams } from "~/hooks/useSearchParam"; import { cn } from "~/utils/cn"; -export type LogsSearchInputProps = { +export type SearchInputProps = { placeholder?: string; + /** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */ + resetParams?: string[]; }; -export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchInputProps) { +export function SearchInput({ + placeholder = "Search logs…", + resetParams = ["cursor", "direction"], +}: SearchInputProps) { const inputRef = useRef(null); const { value, replace, del } = useSearchParams(); - // Get initial search value from URL const initialSearch = value("search") ?? ""; const [text, setText] = useState(initialSearch); const [isFocused, setIsFocused] = useState(false); - // Update text when URL search param changes (only when not focused to avoid overwriting user input) useEffect(() => { const urlSearch = value("search") ?? ""; if (urlSearch !== text && !isFocused) { @@ -30,21 +33,22 @@ export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchIn }, [value, text, isFocused]); const handleSubmit = useCallback(() => { + const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined])); if (text.trim()) { - replace({ search: text.trim() }); + replace({ search: text.trim(), ...resetValues }); } else { - del("search"); + del(["search", ...resetParams]); } - }, [text, replace, del]); + }, [text, replace, del, resetParams]); const handleClear = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setText(""); - del(["search", "cursor", "direction"]); + del(["search", ...resetParams]); }, - [del] + [del, resetParams] ); return ( @@ -82,12 +86,12 @@ export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchIn icon={} accessory={ text.length > 0 ? ( -
- +
+ } parentEnvironment={branchableEnvironment} @@ -410,21 +494,29 @@ export default function Page() {
)} - {canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" + {canPurchaseBranches && branchPricing ? ( + - )} + ) : canUpgrade ? ( +
+ + Upgrade plan for more Preview Branches + + + Upgrade + +
+ ) : null}
@@ -468,15 +560,47 @@ export function BranchFilters() { function UpgradePanel({ limits, canUpgrade, + canPurchaseBranches, + branchPricing, + extraBranches, + maxBranchQuota, + planBranchLimit, }: { limits: { used: number; limit: number; }; canUpgrade: boolean; + canPurchaseBranches: boolean; + branchPricing: { stepSize: number; centsPerStep: number } | null; + extraBranches: number; + maxBranchQuota: number; + planBranchLimit: number; }) { const organization = useOrganization(); + if (canPurchaseBranches && branchPricing) { + return ( + + Purchase more… + + } + /> + ); + } + return ( @@ -502,18 +626,268 @@ function UpgradePanel({ Upgrade - ) : ( - Request more} - defaultValue="help" - /> - )} + ) : null} ); } +function PurchaseBranchesModal({ + branchPricing, + extraBranches, + activeBranches, + maxQuota, + planBranchLimit, + triggerButton, +}: { + branchPricing: { + stepSize: number; + centsPerStep: number; + }; + extraBranches: number; + activeBranches: number; + maxQuota: number; + planBranchLimit: number; + triggerButton?: React.ReactNode; +}) { + const lastSubmission = useActionData(); + const organization = useOrganization(); + const [form, { amount }] = useForm({ + id: "purchase-branches", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: PurchaseSchema }); + }, + shouldRevalidate: "onSubmit", + }); + + const [amountValue, setAmountValue] = useState(extraBranches); + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; + + const [searchParams, setSearchParams] = useSearchParams(); + const [open, setOpen] = useState(false); + useEffect(() => { + const success = searchParams.get("purchaseSuccess"); + if (success) { + setOpen(false); + setSearchParams((s) => { + s.delete("purchaseSuccess"); + return s; + }); + } + }, [searchParams.get("purchaseSuccess")]); + + const state = updateBranchState({ + value: amountValue, + existingValue: extraBranches, + quota: maxQuota, + activeBranches, + planBranchLimit, + }); + const changeClassName = + state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined; + + const pricePerBranch = branchPricing.centsPerStep / branchPricing.stepSize / 100; + const title = extraBranches === 0 ? "Purchase extra branches" : "Add/remove branches"; + + return ( + + + {triggerButton ?? ( + + )} + + + {title} + + +
+
+ + Purchase extra preview branches at {formatCurrency(pricePerBranch, false)}/month per + branch. Reducing the number of branches will take effect at the start of the next + billing cycle (1st of the month). + +
+
+ + + setAmountValue(Number(e.target.value))} + disabled={isLoading} + /> + + {amount.error ?? amount.initialError?.[""]?.[0]} + + {form.error} + +
+ {state === "need_to_archive" ? ( +
+ + You need to archive{" "} + {formatNumber(activeBranches - (planBranchLimit + amountValue))} more{" "} + {activeBranches - (planBranchLimit + amountValue) === 1 ? "branch" : "branches"}{" "} + before you can reduce to this level. + +
+ ) : state === "above_quota" ? ( +
+ + Currently you can only have up to {maxQuota} extra preview branches. Send a + request below to lift your current limit. We'll get back to you soon. + +
+ ) : ( +
+
+ Summary + Total +
+
+ + {formatNumber(extraBranches)} current + extra + + + {formatCurrency(extraBranches * pricePerBranch, true)} + +
+
+ + ({extraBranches} {extraBranches === 1 ? "branch" : "branches"}) + + /mth +
+
+ + {state === "increase" ? "+" : null} + {formatNumber(amountValue - extraBranches)} + + + {state === "increase" ? "+" : null} + {formatCurrency((amountValue - extraBranches) * pricePerBranch, true)} + +
+
+ + ({Math.abs(amountValue - extraBranches)}{" "} + {Math.abs(amountValue - extraBranches) === 1 ? "branch" : "branches"} @{" "} + {formatCurrency(pricePerBranch, true)}/mth) + + /mth +
+
+ + {formatNumber(amountValue)} new total + + + {formatCurrency(amountValue * pricePerBranch, true)} + +
+
+ + ({amountValue} {amountValue === 1 ? "branch" : "branches"}) + + /mth +
+
+ )} +
+ + + + + ) : state === "decrease" || state === "need_to_archive" ? ( + <> + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> + +
+
+ ); +} + +function updateBranchState({ + value, + existingValue, + quota, + activeBranches, + planBranchLimit, +}: { + value: number; + existingValue: number; + quota: number; + activeBranches: number; + planBranchLimit: number; +}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_archive" { + if (value === existingValue) return "no_change"; + if (value < existingValue) { + const newTotalLimit = planBranchLimit + value; + if (activeBranches > newTotalLimit) { + return "need_to_archive"; + } + return "decrease"; + } + if (value > quota) return "above_quota"; + return "increase"; +} + export function NewBranchPanel({ button, parentEnvironment, diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 2fc4c8c5c1f..ac6725caccf 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -34,10 +34,8 @@ function initializeClient() { url: process.env.BILLING_API_URL, apiKey: process.env.BILLING_API_KEY, }); - console.log(`🤑 Billing client initialized: ${process.env.BILLING_API_URL}`); return client; } else { - console.log(`🤑 Billing client not initialized`); } } @@ -409,6 +407,22 @@ export async function setConcurrencyAddOn(organizationId: string, amount: number } } +export async function setBranchesAddOn(organizationId: string, amount: number) { + if (!client) return undefined; + + try { + const result = await client.setAddOn(organizationId, { type: "branches", amount }); + if (!result.success) { + logger.error("Error setting branches add on - no success", { error: result.error }); + return undefined; + } + return result; + } catch (e) { + logger.error("Error setting branches add on - caught error", { error: e }); + return undefined; + } +} + export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) { if (!client) return undefined; diff --git a/apps/webapp/app/v3/services/setBranchesAddOn.server.ts b/apps/webapp/app/v3/services/setBranchesAddOn.server.ts new file mode 100644 index 00000000000..ad3b33337f9 --- /dev/null +++ b/apps/webapp/app/v3/services/setBranchesAddOn.server.ts @@ -0,0 +1,100 @@ +import { BaseService } from "./baseService.server"; +import { tryCatch } from "@trigger.dev/core"; +import { setBranchesAddOn } from "~/services/platform.v3.server"; +import assertNever from "assert-never"; +import { sendToPlain } from "~/utils/plain.server"; +import { uiComponent } from "@team-plain/typescript-sdk"; + +type Input = { + userId: string; + organizationId: string; + action: "purchase" | "quota-increase"; + amount: number; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class SetBranchesAddOnService extends BaseService { + async call({ userId, organizationId, action, amount }: Input): Promise { + switch (action) { + case "purchase": { + const result = await setBranchesAddOn(organizationId, amount); + if (!result) { + return { + success: false, + error: "Failed to update preview branches", + }; + } + + switch (result.result) { + case "success": { + return { success: true }; + } + case "error": { + return { success: false, error: result.error }; + } + case "max_quota_reached": { + return { + success: false, + error: `You can't purchase more than ${result.maxQuota} preview branches without requesting an increase.`, + }; + } + default: { + return { + success: false, + error: "Failed to update preview branches, unknown result.", + }; + } + } + } + case "quota-increase": { + const user = await this._replica.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return { success: false, error: "No matching user found." }; + } + + const organization = await this._replica.organization.findFirst({ + select: { title: true }, + where: { id: organizationId }, + }); + + const [error] = await tryCatch( + sendToPlain({ + userId, + email: user.email, + name: user.name ?? user.displayName ?? user.email, + title: `Preview branches quota request: ${amount}`, + components: [ + uiComponent.text({ + text: `Org: ${organization?.title} (${organizationId})`, + }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.text({ + text: `Total preview branches requested: ${amount}`, + }), + ], + }) + ); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; + } + default: { + assertNever(action); + } + } + } +} From 0098b51ad41f1530b8b77c844b1345155a119840 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 10:31:46 +0000 Subject: [PATCH 04/20] Show the extra branches purchased in the footer UI --- .../route.tsx | 2 +- apps/webapp/app/services/upsertBranch.server.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index e57c3c89818..187e22172dc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -707,7 +707,7 @@ function PurchaseBranchesModal({
- + Purchase extra preview branches at {formatCurrency(pricePerBranch, false)}/month per branch. Reducing the number of branches will take effect at the start of the next billing cycle (1st of the month). diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index a11fdc350ab..3b4e18a58ea 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -5,7 +5,7 @@ import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.serve import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; import { logger } from "./logger.server"; -import { getLimit } from "./platform.v3.server"; +import { getCurrentPlan, getLimit } from "./platform.v3.server"; export class UpsertBranchService { #prismaClient: PrismaClient; @@ -177,7 +177,10 @@ export async function checkBranchLimit( const count = newBranchName ? usedEnvs.filter((env) => env.branchName !== newBranchName).length : usedEnvs.length; - const limit = await getLimit(organizationId, "branches", 100_000_000); + const baseLimit = await getLimit(organizationId, "branches", 100_000_000); + const currentPlan = await getCurrentPlan(organizationId); + const purchasedBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0; + const limit = baseLimit + purchasedBranches; return { used: count, From 3317325931f94177820ff8eb6023b439711a9cd8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 11:17:21 +0000 Subject: [PATCH 05/20] Button style update --- apps/webapp/app/routes/resources.branches.archive.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx index 57ba061bf01..6421e5588e1 100644 --- a/apps/webapp/app/routes/resources.branches.archive.tsx +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -120,7 +120,7 @@ export function ArchiveButton({ } cancelButton={ - + } /> From d9f210b62de34d4e0537eb3cc35a5aa777ccdd28 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 11:17:52 +0000 Subject: [PATCH 06/20] Modal and text improvements --- .../route.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 187e22172dc..563f6461556 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -690,14 +690,14 @@ function PurchaseBranchesModal({ state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined; const pricePerBranch = branchPricing.centsPerStep / branchPricing.stepSize / 100; - const title = extraBranches === 0 ? "Purchase extra branches" : "Add/remove branches"; + const title = extraBranches === 0 ? "Purchase extra branches…" : "Add/remove extra branches…"; return ( {triggerButton ?? ( )} @@ -816,7 +816,9 @@ function PurchaseBranchesModal({ type="submit" disabled={isLoading} > - {`Send request for ${formatNumber(amountValue)}`} + {`Send request for ${formatNumber( + amountValue + )}`} ) : state === "decrease" || state === "need_to_archive" ? ( @@ -828,9 +830,9 @@ function PurchaseBranchesModal({ disabled={isLoading || state === "need_to_archive"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Remove ${formatNumber(extraBranches - amountValue)} ${ - extraBranches - amountValue === 1 ? "branch" : "branches" - }`} + {`Remove ${formatNumber( + extraBranches - amountValue + )} ${extraBranches - amountValue === 1 ? "branch" : "branches"}`} ) : ( @@ -842,9 +844,9 @@ function PurchaseBranchesModal({ disabled={isLoading || state === "no_change"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Purchase ${formatNumber(amountValue - extraBranches)} ${ - amountValue - extraBranches === 1 ? "branch" : "branches" - }`} + {`Purchase ${formatNumber( + amountValue - extraBranches + )} ${amountValue - extraBranches === 1 ? "branch" : "branches"}`} ) From cee54f50863af3b1163954ea2b9acb6e906516c1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 14:17:00 +0000 Subject: [PATCH 07/20] Update team member layout and show member count footer bar --- .../route.tsx | 215 ++++++++++-------- 1 file changed, 120 insertions(+), 95 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index c83e10ced0f..dd039cba0b4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -1,6 +1,6 @@ import { useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { EnvelopeIcon, LockOpenIcon, TrashIcon, UserPlusIcon } from "@heroicons/react/20/solid"; +import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid"; import { Form, type MetaFunction, useActionData } from "@remix-run/react"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useState } from "react"; @@ -9,11 +9,7 @@ import invariant from "tiny-invariant"; import { z } from "zod"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { - MainHorizontallyCenteredContainer, - PageBody, - PageContainer, -} from "~/components/layout/AppLayout"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Alert, AlertCancel, @@ -27,7 +23,6 @@ import { import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; -import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; @@ -159,97 +154,120 @@ export default function Page() { ))} + {!requiresUpgrade && ( + + Invite a team member + + )} - - - - Members{" "} - - ({limits.used}/{limits.limit}) - - -
    - {members.map((member) => ( -
  • - -
    - - {member.user.name}{" "} - {member.user.id === user.id && (You)} - - {member.user.email} -
    -
    - -
    -
  • - ))} -
- - {invites.length > 0 && ( - <> - Pending invites -
    - {invites.map((invite) => ( -
  • -
    - -
    + +
    +
    +
    + {invites.length > 0 && ( + <> + Pending invites +
      + {invites.map((invite) => ( +
    • +
      + +
      +
      + {invite.email} + + Invite sent {} + +
      +
      + + +
      +
    • + ))} +
    + + )} + Active team members +
      + {members.map((member) => ( +
    • +
      - {invite.email} - - Invite sent {} - + + {member.user.name}{" "} + {member.user.id === user.id && ( + (You) + )} + + {member.user.email}
      -
      - - +
      +
    • ))}
    - - )} - - {requiresUpgrade ? ( - - Upgrade - +
    +
    + +
    + + + + + +
    } - panelClassName="mt-4" - > - - You've used all {limits.limit} of your available team members. Upgrade your plan to - enable more. - - - ) : ( -
    - - Invite a team member + content={`${Math.round((limits.used / limits.limit) * 100)}%`} + /> +
    + {requiresUpgrade ? ( + + You've used all {limits.limit} of your team members. Upgrade your plan to enable + more. + + ) : ( + + You've used {limits.used}/{limits.limit} of your team members + + )} + + Upgrade
    - )} - +
    +
    ); @@ -275,6 +293,7 @@ function LeaveRemoveButton({ Leave team } + disableHoverableContent content="An organization requires at least 1 team member" /> ); @@ -332,7 +351,7 @@ function LeaveTeamModal({ return ( setOpen(o)}> - + @@ -341,7 +360,7 @@ function LeaveTeamModal({ - +
    setOpen(false)}> @@ -359,7 +378,7 @@ function ResendButton({ invite }: { invite: Invite }) { return ( -
    @@ -373,11 +392,17 @@ function RevokeButton({ invite }: { invite: Invite }) {
    -
    ); From 2e2838917350dcd65dc70a9278fafb129d344fe2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 14:47:28 +0000 Subject: [PATCH 09/20] Style improvements --- .../_app.orgs.$organizationSlug.settings.team/route.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 64e739ceb0b..07b15f321a3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -167,7 +167,7 @@ export default function Page() {
    -
    +
    {invites.length > 0 && ( <> @@ -194,7 +194,7 @@ export default function Page() { )} Active team members -
      +
        {members.map((member) => (
      • Date: Tue, 10 Mar 2026 16:45:35 +0000 Subject: [PATCH 10/20] Implements new seats purchase logic and UI --- .../app/presenters/TeamPresenter.server.ts | 21 +- .../route.tsx | 86 +++- .../route.tsx | 413 +++++++++++++++++- .../webapp/app/services/platform.v3.server.ts | 17 +- .../app/v3/services/setSeatsAddOn.server.ts | 100 +++++ 5 files changed, 594 insertions(+), 43 deletions(-) create mode 100644 apps/webapp/app/v3/services/setSeatsAddOn.server.ts diff --git a/apps/webapp/app/presenters/TeamPresenter.server.ts b/apps/webapp/app/presenters/TeamPresenter.server.ts index 25300af191d..8b84a65a67c 100644 --- a/apps/webapp/app/presenters/TeamPresenter.server.ts +++ b/apps/webapp/app/presenters/TeamPresenter.server.ts @@ -1,5 +1,5 @@ import { getTeamMembersAndInvites } from "~/models/member.server"; -import { getLimit } from "~/services/platform.v3.server"; +import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server"; import { BasePresenter } from "./v3/basePresenter.server"; export class TeamPresenter extends BasePresenter { @@ -13,7 +13,19 @@ export class TeamPresenter extends BasePresenter { return; } - const limit = await getLimit(organizationId, "teamMembers", 100_000_000); + const [baseLimit, currentPlan, plans] = await Promise.all([ + getLimit(organizationId, "teamMembers", 100_000_000), + getCurrentPlan(organizationId), + getPlans(), + ]); + + const canPurchaseSeats = + currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true; + const extraSeats = currentPlan?.v3Subscription?.addOns?.seats?.purchased ?? 0; + const maxSeatQuota = currentPlan?.v3Subscription?.addOns?.seats?.quota ?? 0; + const planSeatLimit = currentPlan?.v3Subscription?.plan?.limits.teamMembers.number ?? 0; + const seatPricing = plans?.addOnPricing.seats ?? null; + const limit = baseLimit + extraSeats; return { ...result, @@ -21,6 +33,11 @@ export class TeamPresenter extends BasePresenter { used: result.members.length + result.invites.length, limit, }, + canPurchaseSeats, + extraSeats, + seatPricing, + maxSeatQuota, + planSeatLimit, }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 8f82153052d..4dd1d440e31 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -1,6 +1,11 @@ import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { EnvelopeIcon, LockOpenIcon, UserPlusIcon } from "@heroicons/react/20/solid"; +import { + ArrowUpCircleIcon, + EnvelopeIcon, + LockOpenIcon, + UserPlusIcon, +} from "@heroicons/react/20/solid"; import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; @@ -28,7 +33,13 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { scheduleEmail } from "~/services/email.server"; import { requireUserId } from "~/services/session.server"; -import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; +import { + acceptInvitePath, + inviteTeamMemberPath, + organizationTeamPath, + v3BillingPath, +} from "~/utils/pathBuilder"; +import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; const Params = z.object({ organizationSlug: z.string(), @@ -122,7 +133,8 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { limits } = useTypedLoaderData(); + const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } = + useTypedLoaderData(); const [total, setTotal] = useState(limits.used); const organization = useOrganization(); const lastSubmission = useActionData(); @@ -150,25 +162,55 @@ export default function Page() { title="Invite team members" description={`Invite new team members to ${organization.title}.`} /> - {total > limits.limit && ( - - Upgrade - - } - panelClassName="mb-4" - > - - You've used all {limits.limit} of your available team members. Upgrade your plan to - add more. - - - )} + {total > limits.limit && + (canPurchaseSeats && seatPricing ? ( + Purchase more seats…} + redirectTo={inviteTeamMemberPath(organization)} + /> + } + panelClassName="mb-4" + > + + You've used all {limits.limit} of your available team members. Purchase extra seats + to add more. + + + ) : ( + + Upgrade + + } + panelClassName="mb-4" + > + + You've used all {limits.limit} of your available team members. Upgrade your plan to + add more. + + + ))}
        diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 07b15f321a3..5bbf2d6a496 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -1,8 +1,21 @@ -import { useForm } from "@conform-to/react"; +import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { + Form, + type MetaFunction, + useActionData, + useNavigation, + useSearchParams, +} from "@remix-run/react"; +import { + type ActionFunctionArgs, + type ActionFunction, + type LoaderFunctionArgs, + json, +} from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; import { useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; @@ -22,16 +35,25 @@ import { } from "~/components/primitives/Alert"; import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; import { Header2, Header3 } from "~/components/primitives/Headers"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { InputNumberStepper } from "~/components/primitives/InputNumberStepper"; +import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { cn } from "~/utils/cn"; import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { removeTeamMember } from "~/models/member.server"; -import { redirectWithSuccessMessage } from "~/models/message.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { requireUserId } from "~/services/session.server"; import { @@ -41,6 +63,9 @@ import { revokeInvitePath, v3BillingPath, } from "~/utils/pathBuilder"; +import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; +import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; export const meta: MetaFunction = () => { return [ @@ -84,12 +109,76 @@ const schema = z.object({ memberId: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { +const PurchaseSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("purchase"), + amount: z.coerce.number().min(0, "Amount must be 0 or more"), + }), + z.object({ + action: z.literal("quota-increase"), + amount: z.coerce.number().min(1, "Amount must be greater than 0"), + }), +]); + +export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug } = params; invariant(organizationSlug, "organizationSlug not found"); const formData = await request.formData(); + const formType = formData.get("_formType"); + + if (formType === "purchase-seats") { + const redirectTo = formData.get("redirectTo"); + const redirectPath = + typeof redirectTo === "string" && redirectTo + ? redirectTo + : organizationTeamPath({ slug: organizationSlug }); + + const org = await $replica.organization.findFirst({ + where: { slug: organizationSlug }, + select: { id: true }, + }); + + if (!org) { + throw redirectWithErrorMessage(redirectPath, request, "Organization not found"); + } + + const submission = parse(formData, { schema: PurchaseSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const service = new SetSeatsAddOnService(); + const [error, result] = await tryCatch( + service.call({ + userId, + organizationId: org.id, + action: submission.value.action, + amount: submission.value.amount, + }) + ); + + if (error) { + submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; + return json(submission); + } + + if (!result.success) { + submission.error.amount = [result.error]; + return json(submission); + } + + return redirectWithSuccessMessage( + `${redirectPath}?purchaseSuccess=true`, + request, + submission.value.action === "purchase" + ? "Seats updated successfully" + : "Requested extra seats, we'll get back to you soon." + ); + } + const submission = parse(formData, { schema }); if (!submission.value || submission.intent !== "submit") { @@ -121,11 +210,23 @@ type Member = UseDataFunctionReturn["members"][number]; type Invite = UseDataFunctionReturn["invites"][number]; export default function Page() { - const { members, invites, limits } = useTypedLoaderData(); + const { + members, + invites, + limits, + canPurchaseSeats, + extraSeats, + seatPricing, + maxSeatQuota, + planSeatLimit, + } = useTypedLoaderData(); const user = useUser(); const organization = useOrganization(); + const plan = useCurrentPlan(); const requiresUpgrade = limits.used >= limits.limit; + const canUpgrade = + plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.teamMembers.canExceed; return ( @@ -224,7 +325,7 @@ export default function Page() {
    -
    +
    @@ -254,17 +355,29 @@ export default function Page() {
    {requiresUpgrade ? ( - You've used all {limits.limit} of your team members. Upgrade your plan to enable - more. + You've used all {limits.limit} of your seats.{" "} + {canPurchaseSeats + ? "Purchase more seats to invite more team members." + : "Upgrade your plan to invite more team members."} ) : ( - You've used {limits.used}/{limits.limit} of your team members + You've used {limits.used}/{limits.limit} of your seats )} - - Upgrade - + {canPurchaseSeats && seatPricing ? ( + + ) : canUpgrade ? ( + + Upgrade + + ) : null}
    @@ -424,11 +537,13 @@ function ResendButton({ invite }: { invite: Invite }) { ); @@ -452,7 +567,269 @@ function RevokeButton({ invite }: { invite: Invite }) { } content="Revoke invite" disableHoverableContent + asChild /> ); } + +export function PurchaseSeatsModal({ + seatPricing, + extraSeats, + usedSeats, + maxQuota, + planSeatLimit, + triggerButton, + redirectTo, +}: { + seatPricing: { + stepSize: number; + centsPerStep: number; + }; + extraSeats: number; + usedSeats: number; + maxQuota: number; + planSeatLimit: number; + triggerButton?: React.ReactNode; + redirectTo?: string; +}) { + const lastSubmission = useActionData(); + const organization = useOrganization(); + const [form, { amount }] = useForm({ + id: "purchase-seats", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: PurchaseSchema }); + }, + shouldRevalidate: "onSubmit", + }); + + const [amountValue, setAmountValue] = useState(extraSeats); + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; + + const [searchParams, setSearchParams] = useSearchParams(); + const [open, setOpen] = useState(false); + useEffect(() => { + const success = searchParams.get("purchaseSuccess"); + if (success) { + setOpen(false); + setSearchParams((s) => { + s.delete("purchaseSuccess"); + return s; + }); + } + }, [searchParams.get("purchaseSuccess")]); + + const state = updateSeatState({ + value: amountValue, + existingValue: extraSeats, + quota: maxQuota, + usedSeats, + planSeatLimit, + }); + const changeClassName = + state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined; + + const pricePerSeat = seatPricing.centsPerStep / seatPricing.stepSize / 100; + const title = extraSeats === 0 ? "Purchase extra seats…" : "Add/remove extra seats…"; + + return ( + + + {triggerButton ?? ( + + )} + + + {title} +
    + + {redirectTo && } +
    +
    + + Purchase extra seats at {formatCurrency(pricePerSeat, false)}/month per seat. + Reducing the number of seats will take effect at the start of the next billing cycle + (1st of the month). + +
    +
    + + + setAmountValue(Number(e.target.value))} + disabled={isLoading} + /> + + {amount.error ?? amount.initialError?.[""]?.[0]} + + {form.error} + +
    + {state === "need_to_remove_members" ? ( +
    + + You need to remove {formatNumber(usedSeats - (planSeatLimit + amountValue))}{" "} + {usedSeats - (planSeatLimit + amountValue) === 1 + ? "team member or pending invite" + : "team members or pending invites"}{" "} + before you can reduce to this level. + +
    + ) : state === "above_quota" ? ( +
    + + Currently you can only have up to {maxQuota} extra seats. Send a request below to + lift your current limit. We'll get back to you soon. + +
    + ) : ( +
    +
    + Summary + Total +
    +
    + + {formatNumber(extraSeats)} current + extra + + + {formatCurrency(extraSeats * pricePerSeat, true)} + +
    +
    + + ({extraSeats} {extraSeats === 1 ? "seat" : "seats"}) + + /mth +
    +
    + + {state === "increase" ? "+" : null} + {formatNumber(amountValue - extraSeats)} + + + {state === "increase" ? "+" : null} + {formatCurrency((amountValue - extraSeats) * pricePerSeat, true)} + +
    +
    + + ({Math.abs(amountValue - extraSeats)}{" "} + {Math.abs(amountValue - extraSeats) === 1 ? "seat" : "seats"} @{" "} + {formatCurrency(pricePerSeat, true)}/mth) + + /mth +
    +
    + + {formatNumber(amountValue)} new total + + + {formatCurrency(amountValue * pricePerSeat, true)} + +
    +
    + + ({amountValue} {amountValue === 1 ? "seat" : "seats"}) + + /mth +
    +
    + )} +
    + + + + + ) : state === "decrease" || state === "need_to_remove_members" ? ( + <> + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> + +
    +
    + ); +} + +function updateSeatState({ + value, + existingValue, + quota, + usedSeats, + planSeatLimit, +}: { + value: number; + existingValue: number; + quota: number; + usedSeats: number; + planSeatLimit: number; +}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_remove_members" { + if (value === existingValue) return "no_change"; + if (value < existingValue) { + const newTotalLimit = planSeatLimit + value; + if (usedSeats > newTotalLimit) { + return "need_to_remove_members"; + } + return "decrease"; + } + if (value > quota) return "above_quota"; + return "increase"; +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index ac6725caccf..a27c512ef43 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -35,7 +35,6 @@ function initializeClient() { apiKey: process.env.BILLING_API_KEY, }); return client; - } else { } } @@ -407,6 +406,22 @@ export async function setConcurrencyAddOn(organizationId: string, amount: number } } +export async function setSeatsAddOn(organizationId: string, amount: number) { + if (!client) return undefined; + + try { + const result = await client.setAddOn(organizationId, { type: "seats", amount }); + if (!result.success) { + logger.error("Error setting seats add on - no success", { error: result.error }); + return undefined; + } + return result; + } catch (e) { + logger.error("Error setting seats add on - caught error", { error: e }); + return undefined; + } +} + export async function setBranchesAddOn(organizationId: string, amount: number) { if (!client) return undefined; diff --git a/apps/webapp/app/v3/services/setSeatsAddOn.server.ts b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts new file mode 100644 index 00000000000..5255d96b8de --- /dev/null +++ b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts @@ -0,0 +1,100 @@ +import { BaseService } from "./baseService.server"; +import { tryCatch } from "@trigger.dev/core"; +import { setSeatsAddOn } from "~/services/platform.v3.server"; +import assertNever from "assert-never"; +import { sendToPlain } from "~/utils/plain.server"; +import { uiComponent } from "@team-plain/typescript-sdk"; + +type Input = { + userId: string; + organizationId: string; + action: "purchase" | "quota-increase"; + amount: number; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class SetSeatsAddOnService extends BaseService { + async call({ userId, organizationId, action, amount }: Input): Promise { + switch (action) { + case "purchase": { + const result = await setSeatsAddOn(organizationId, amount); + if (!result) { + return { + success: false, + error: "Failed to update seats", + }; + } + + switch (result.result) { + case "success": { + return { success: true }; + } + case "error": { + return { success: false, error: result.error }; + } + case "max_quota_reached": { + return { + success: false, + error: `You can't purchase more than ${result.maxQuota} seats without requesting an increase.`, + }; + } + default: { + return { + success: false, + error: "Failed to update seats, unknown result.", + }; + } + } + } + case "quota-increase": { + const user = await this._replica.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return { success: false, error: "No matching user found." }; + } + + const organization = await this._replica.organization.findFirst({ + select: { title: true }, + where: { id: organizationId }, + }); + + const [error] = await tryCatch( + sendToPlain({ + userId, + email: user.email, + name: user.name ?? user.displayName ?? user.email, + title: `Seats quota request: ${amount}`, + components: [ + uiComponent.text({ + text: `Org: ${organization?.title} (${organizationId})`, + }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.text({ + text: `Total seats requested: ${amount}`, + }), + ], + }) + ); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; + } + default: { + assertNever(action); + } + } + } +} From a03061e6a4cae082721384651639938c407a63ca Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 16:58:53 +0000 Subject: [PATCH 11/20] Switch PurchaseSeatsModal to useFetcher for scoped loading state, cross-route error handling, and fix open redirect vulnerability. --- .../route.tsx | 8 +-- .../route.tsx | 57 ++++++------------- 2 files changed, 17 insertions(+), 48 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 4dd1d440e31..44990abaa6e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -33,12 +33,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { scheduleEmail } from "~/services/email.server"; import { requireUserId } from "~/services/session.server"; -import { - acceptInvitePath, - inviteTeamMemberPath, - organizationTeamPath, - v3BillingPath, -} from "~/utils/pathBuilder"; +import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; const Params = z.object({ @@ -177,7 +172,6 @@ export default function Page() { maxQuota={maxSeatQuota} planSeatLimit={planSeatLimit} triggerButton={} - redirectTo={inviteTeamMemberPath(organization)} /> } panelClassName="mb-4" diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 5bbf2d6a496..116f18628a0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -2,19 +2,8 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { - Form, - type MetaFunction, - useActionData, - useNavigation, - useSearchParams, -} from "@remix-run/react"; -import { - type ActionFunctionArgs, - type ActionFunction, - type LoaderFunctionArgs, - json, -} from "@remix-run/server-runtime"; +import { Form, type MetaFunction, useActionData, useFetcher, useNavigation } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -129,11 +118,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const formType = formData.get("_formType"); if (formType === "purchase-seats") { - const redirectTo = formData.get("redirectTo"); - const redirectPath = - typeof redirectTo === "string" && redirectTo - ? redirectTo - : organizationTeamPath({ slug: organizationSlug }); + const redirectPath = organizationTeamPath({ slug: organizationSlug }); const org = await $replica.organization.findFirst({ where: { slug: organizationSlug }, @@ -171,7 +156,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } return redirectWithSuccessMessage( - `${redirectPath}?purchaseSuccess=true`, + redirectPath, request, submission.value.action === "purchase" ? "Seats updated successfully" @@ -512,11 +497,9 @@ function ResendButton({ invite }: { invite: Invite }) { prevSubmitting.current = isSubmitting; }, [isSubmitting]); + const cooldownActive = cooldown > 0; useEffect(() => { - if (cooldown <= 0) { - clearInterval(intervalRef.current); - return; - } + if (!cooldownActive) return; intervalRef.current = setInterval(() => { setCooldown((c) => { @@ -529,7 +512,7 @@ function ResendButton({ invite }: { invite: Invite }) { }, 1000); return () => clearInterval(intervalRef.current); - }, [cooldown > 0]); // only re-run when transitioning between active/inactive + }, [cooldownActive]); const isDisabled = isSubmitting || cooldown > 0; @@ -580,7 +563,6 @@ export function PurchaseSeatsModal({ maxQuota, planSeatLimit, triggerButton, - redirectTo, }: { seatPricing: { stepSize: number; @@ -591,13 +573,12 @@ export function PurchaseSeatsModal({ maxQuota: number; planSeatLimit: number; triggerButton?: React.ReactNode; - redirectTo?: string; }) { - const lastSubmission = useActionData(); + const fetcher = useFetcher(); const organization = useOrganization(); const [form, { amount }] = useForm({ id: "purchase-seats", - lastSubmission: lastSubmission as any, + lastSubmission: fetcher.data as any, onValidate({ formData }) { return parse(formData, { schema: PurchaseSchema }); }, @@ -605,21 +586,16 @@ export function PurchaseSeatsModal({ }); const [amountValue, setAmountValue] = useState(extraSeats); - const navigation = useNavigation(); - const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; + const isLoading = fetcher.state !== "idle"; - const [searchParams, setSearchParams] = useSearchParams(); const [open, setOpen] = useState(false); + const prevFetcherState = useRef(fetcher.state); useEffect(() => { - const success = searchParams.get("purchaseSuccess"); - if (success) { + if (prevFetcherState.current !== "idle" && fetcher.state === "idle" && !fetcher.data) { setOpen(false); - setSearchParams((s) => { - s.delete("purchaseSuccess"); - return s; - }); } - }, [searchParams.get("purchaseSuccess")]); + prevFetcherState.current = fetcher.state; + }, [fetcher.state, fetcher.data]); const state = updateSeatState({ value: amountValue, @@ -645,9 +621,8 @@ export function PurchaseSeatsModal({ {title} -
    + - {redirectTo && }
    @@ -803,7 +778,7 @@ export function PurchaseSeatsModal({ } /> - +
); From f6039d0d3cd978062fdac592964de37f3876fff0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:26:45 +0000 Subject: [PATCH 12/20] Refactor PurchaseSeatsModal to use useFetcher for isolated state and cross-route compatibility --- .../route.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 116f18628a0..a65c3ccee98 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -42,7 +42,7 @@ import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { removeTeamMember } from "~/models/member.server"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { requireUserId } from "~/services/session.server"; import { @@ -118,15 +118,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const formType = formData.get("_formType"); if (formType === "purchase-seats") { - const redirectPath = organizationTeamPath({ slug: organizationSlug }); - const org = await $replica.organization.findFirst({ where: { slug: organizationSlug }, select: { id: true }, }); if (!org) { - throw redirectWithErrorMessage(redirectPath, request, "Organization not found"); + return json({ ok: false, error: "Organization not found" } as const); } const submission = parse(formData, { schema: PurchaseSchema }); @@ -155,13 +153,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } - return redirectWithSuccessMessage( - redirectPath, - request, - submission.value.action === "purchase" - ? "Seats updated successfully" - : "Requested extra seats, we'll get back to you soon." - ); + return json({ ok: true } as const); } const submission = parse(formData, { schema }); @@ -576,9 +568,13 @@ export function PurchaseSeatsModal({ }) { const fetcher = useFetcher(); const organization = useOrganization(); + const lastSubmission = + fetcher.data && typeof fetcher.data === "object" && "intent" in fetcher.data + ? fetcher.data + : undefined; const [form, { amount }] = useForm({ id: "purchase-seats", - lastSubmission: fetcher.data as any, + lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema: PurchaseSchema }); }, @@ -586,15 +582,23 @@ export function PurchaseSeatsModal({ }); const [amountValue, setAmountValue] = useState(extraSeats); + useEffect(() => { + setAmountValue(extraSeats); + }, [extraSeats]); const isLoading = fetcher.state !== "idle"; const [open, setOpen] = useState(false); - const prevFetcherState = useRef(fetcher.state); useEffect(() => { - if (prevFetcherState.current !== "idle" && fetcher.state === "idle" && !fetcher.data) { + const data = fetcher.data; + if ( + fetcher.state === "idle" && + data !== null && + typeof data === "object" && + "ok" in data && + data.ok + ) { setOpen(false); } - prevFetcherState.current = fetcher.state; }, [fetcher.state, fetcher.data]); const state = updateSeatState({ From ae9aa410e573f8dcd62975bb958b23f38f7fe56d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:36:53 +0000 Subject: [PATCH 13/20] Improved some copy --- .../route.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index a65c3ccee98..f7014a3dc4e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -2,7 +2,13 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, type MetaFunction, useActionData, useFetcher, useNavigation } from "@remix-run/react"; +import { + Form, + type MetaFunction, + useActionData, + useFetcher, + useNavigation, +} from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { useEffect, useRef, useState } from "react"; @@ -630,9 +636,9 @@ export function PurchaseSeatsModal({
- Purchase extra seats at {formatCurrency(pricePerSeat, false)}/month per seat. - Reducing the number of seats will take effect at the start of the next billing cycle - (1st of the month). + Purchase extra seats at {formatCurrency(pricePerSeat, true)}/month per seat. + Reducing seats will take effect at the start of your next billing cycle (on the 1st + of the month).
From 0bb5fc1ae7cc8c75fde616b36087f68021bcd1de Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:43:51 +0000 Subject: [PATCH 14/20] =?UTF-8?q?Show=20a=20tooltip=20on=20the=20invite=20?= =?UTF-8?q?button=20if=20you=20don=E2=80=99t=20have=20enough=20seats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index f7014a3dc4e..2d2546e4005 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -238,7 +238,17 @@ export default function Page() { ))} - {!requiresUpgrade && ( + {requiresUpgrade ? ( + + Invite a team member + + } + content="Purchase more seats to invite more team members" + disableHoverableContent + /> + ) : ( Date: Tue, 10 Mar 2026 17:49:17 +0000 Subject: [PATCH 15/20] Dependency array fix suggestion from code rabbit --- .../route.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 563f6461556..631abe5e76a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -667,17 +667,17 @@ function PurchaseBranchesModal({ const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; const [searchParams, setSearchParams] = useSearchParams(); + const purchaseSuccess = searchParams.get("purchaseSuccess"); const [open, setOpen] = useState(false); useEffect(() => { - const success = searchParams.get("purchaseSuccess"); - if (success) { + if (purchaseSuccess) { setOpen(false); setSearchParams((s) => { s.delete("purchaseSuccess"); return s; }); } - }, [searchParams.get("purchaseSuccess")]); + }, [purchaseSuccess, setSearchParams]); const state = updateBranchState({ value: amountValue, From b6be53b277ae26e01c0362ac2739f6ad13737a13 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:52:25 +0000 Subject: [PATCH 16/20] Code rabbit fix --- .../_app.orgs.$organizationSlug.settings.team/route.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 2d2546e4005..97a6bf5d890 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -107,11 +107,14 @@ const schema = z.object({ const PurchaseSchema = z.discriminatedUnion("action", [ z.object({ action: z.literal("purchase"), - amount: z.coerce.number().min(0, "Amount must be 0 or more"), + amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"), }), z.object({ action: z.literal("quota-increase"), - amount: z.coerce.number().min(1, "Amount must be greater than 0"), + amount: z.coerce + .number() + .int("Must be a whole number") + .min(1, "Amount must be greater than 0"), }), ]); From a890df50623df3bca72b3b62540225a9e0216394 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:53:22 +0000 Subject: [PATCH 17/20] Add aria-label --- .../routes/_app.orgs.$organizationSlug.settings.team/route.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 97a6bf5d890..14988e55c0c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -557,6 +557,7 @@ function RevokeButton({ invite }: { invite: Invite }) { variant="danger/small" LeadingIcon={NoSymbolIcon} leadingIconClassName="text-white" + aria-label="Revoke invite" /> } content="Revoke invite" From b9bcbbc53eeb6cc9022615747fb0a3f447ee9419 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:57:37 +0000 Subject: [PATCH 18/20] Import fix (code rabbit suggestion) --- .../routes/_app.orgs.$organizationSlug.settings.team/route.tsx | 2 +- apps/webapp/app/v3/services/setSeatsAddOn.server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 14988e55c0c..e3b2834adf0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -10,7 +10,7 @@ import { useNavigation, } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { tryCatch } from "@trigger.dev/core"; +import { tryCatch } from "@trigger.dev/core/utils"; import { useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; diff --git a/apps/webapp/app/v3/services/setSeatsAddOn.server.ts b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts index 5255d96b8de..b9159c2ee7c 100644 --- a/apps/webapp/app/v3/services/setSeatsAddOn.server.ts +++ b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts @@ -1,5 +1,5 @@ import { BaseService } from "./baseService.server"; -import { tryCatch } from "@trigger.dev/core"; +import { tryCatch } from "@trigger.dev/core/utils"; import { setSeatsAddOn } from "~/services/platform.v3.server"; import assertNever from "assert-never"; import { sendToPlain } from "~/utils/plain.server"; From 90d9f9ad11a78a84228125d2aff74f199aab98ea Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 17:59:13 +0000 Subject: [PATCH 19/20] Type fix (code rabbit) --- .../routes/_app.orgs.$organizationSlug.settings.team/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index e3b2834adf0..0cc9a3b08ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -584,7 +584,7 @@ export function PurchaseSeatsModal({ usedSeats: number; maxQuota: number; planSeatLimit: number; - triggerButton?: React.ReactNode; + triggerButton?: React.ReactElement; }) { const fetcher = useFetcher(); const organization = useOrganization(); From bcbb07c454e785d0cda2f89474aaf0d42af82f48 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 10 Mar 2026 18:20:19 +0000 Subject: [PATCH 20/20] Minor code rabbit suggested fixes --- .../route.tsx | 1 - .../_app.orgs.$organizationSlug.settings.team/route.tsx | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 631abe5e76a..2c8af504a00 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -652,7 +652,6 @@ function PurchaseBranchesModal({ triggerButton?: React.ReactNode; }) { const lastSubmission = useActionData(); - const organization = useOrganization(); const [form, { amount }] = useForm({ id: "purchase-branches", lastSubmission: lastSubmission as any, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 0cc9a3b08ce..dc71bc5585f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -211,6 +211,7 @@ export default function Page() { const plan = useCurrentPlan(); const requiresUpgrade = limits.used >= limits.limit; + const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0; const canUpgrade = plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.teamMembers.canExceed; @@ -339,14 +340,14 @@ export default function Page() { r="10" cx="12" cy="12" - strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`} + strokeDasharray={`${usageRatio * 62.8} 62.8`} strokeDashoffset="0" strokeLinecap="round" />
} - content={`${Math.round((limits.used / limits.limit) * 100)}%`} + content={`${Math.round(usageRatio * 100)}%`} />
{requiresUpgrade ? (