feat(webapp): self serve preview branches and team members (#3201)

## Adds 2 self serve features

### 1. self serve preview branches

- Copies the patterns of the self serve concurrency
- Self serve only available on Pro plan (otherwise you are linked to the
billing plans page)
- Global self serve branches limit: 180 (+20 for the Pro plan). It can
be overridden per Org
- You need to archive branches before reducing the number of extra
branches you're paying for
- Branches are removed immediately but remain billed until the end of
the billing cycle like extra concurrency

### 2. self serve team members

- Copies the patterns of the self serve concurrency
- Self serve only available on Pro plan (otherwise you are linked to the
billing plans page)
- Global self serve members is unlimited but can be limited with the
same env var quota and overridden per org if needed
- You need to remove team members before reducing the number of members
you pay for
- Team members are removed immediately but remain billed until the end
of the billing cycle like extra concurrency
This commit is contained in:
James Ritchie
2026-03-12 14:56:10 +00:00
committed by GitHub
parent 21fdb528f5
commit fea8ae4e53
15 changed files with 1355 additions and 232 deletions
@@ -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<HTMLInputElement>(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<HTMLButtonElement>) => {
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={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<div className="-mr-1 flex items-center gap-1">
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<div className="-mr-1 flex items-center gap-1.5">
<ShortcutKey shortcut={{ key: "enter" }} variant="medium" className="border-none" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
@@ -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,
};
}
}
@@ -4,6 +4,7 @@ import { type Prisma, type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { checkBranchLimit } from "~/services/upsertBranch.server";
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
@@ -110,6 +111,11 @@ export class BranchesPresenter {
limit: 0,
isAtLimit: true,
},
canPurchaseBranches: false,
extraBranches: 0,
branchPricing: null,
maxBranchQuota: 0,
planBranchLimit: 0,
};
}
@@ -131,6 +137,18 @@ export class BranchesPresenter {
// Limits
const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id);
const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
getPlans(),
]);
const canPurchaseBranches =
currentPlan?.v3Subscription?.plan?.limits.branches.canExceed === true;
const extraBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
const maxBranchQuota = currentPlan?.v3Subscription?.addOns?.branches?.quota ?? 0;
const planBranchLimit = currentPlan?.v3Subscription?.plan?.limits.branches.number ?? 0;
const branchPricing = plans?.addOnPricing.branches ?? null;
const branches = await this.#prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
@@ -191,6 +209,11 @@ export class BranchesPresenter {
}),
hasFilters,
limits,
canPurchaseBranches,
extraBranches,
branchPricing,
maxBranchQuota,
planBranchLimit,
};
}
}
@@ -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";
@@ -29,6 +34,7 @@ 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 { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
const Params = z.object({
organizationSlug: z.string(),
@@ -122,7 +128,8 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const { limits } = useTypedLoaderData<typeof loader>();
const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } =
useTypedLoaderData<typeof loader>();
const [total, setTotal] = useState(limits.used);
const organization = useOrganization();
const lastSubmission = useActionData();
@@ -150,25 +157,54 @@ export default function Page() {
title="Invite team members"
description={`Invite new team members to ${organization.title}.`}
/>
{total > limits.limit && (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
accessory={
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
}
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
add more.
</Paragraph>
</InfoPanel>
)}
{total > limits.limit &&
(canPurchaseSeats && seatPricing ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Need more seats?"
accessory={
<PurchaseSeatsModal
seatPricing={seatPricing}
extraSeats={extraSeats}
usedSeats={limits.used}
maxQuota={maxSeatQuota}
planSeatLimit={planSeatLimit}
triggerButton={<Button variant="primary/small">Purchase more seats</Button>}
/>
}
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Purchase extra seats
to add more.
</Paragraph>
</InfoPanel>
) : (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
accessory={
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
}
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
add more.
</Paragraph>
</InfoPanel>
))}
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup>
@@ -1,23 +1,17 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
ArrowRightIcon,
ArrowUpCircleIcon,
CheckIcon,
MagnifyingGlassIcon,
PlusIcon,
} from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useActionData, useLocation, useSearchParams } from "@remix-run/react";
import { Form, useActionData, useFetcher, useLocation, useSearchParams } from "@remix-run/react";
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { GitMeta } from "@trigger.dev/core/v3";
import { GitMeta, tryCatch } from "@trigger.dev/core/v3";
import { useCallback, useEffect, useState } from "react";
import { SearchInput } from "~/components/primitives/SearchInput";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels";
import { Feedback } from "~/components/Feedback";
import { GitMetadata } from "~/components/GitMetadata";
import { V4Title } from "~/components/V4Badge";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
@@ -41,12 +35,14 @@ import { Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
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 { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
@@ -62,16 +58,26 @@ import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useThrottle } from "~/hooks/useThrottle";
import { findProjectBySlug } from "~/models/project.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
import { cn } from "~/utils/cn";
import { branchesPath, docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import {
branchesPath,
docsPath,
EnvironmentParamSchema,
ProjectParamSchema,
v3BillingPath,
} from "~/utils/pathBuilder";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { SetBranchesAddOnService } from "~/v3/services/setBranchesAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { ArchiveButton } from "../resources.branches.archive";
import { IconArrowBearRight2 } from "@tabler/icons-react";
export const BranchesOptions = z.object({
search: z.string().optional(),
@@ -119,10 +125,68 @@ export const schema = CreateBranchOptions.and(
})
);
export async function action({ request }: ActionFunctionArgs) {
const PurchaseSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("purchase"),
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()
.int("Must be a whole number")
.min(1, "Amount must be greater than 0"),
}),
]);
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const formType = formData.get("_formType");
if (formType === "purchase-branches") {
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const redirectPath = branchesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!project) {
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const submission = parse(formData, { schema: PurchaseSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const service = new SetBranchesAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
organizationId: project.organizationId,
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 json({ ok: true } as const);
}
const submission = parse(formData, { schema });
if (!submission.value) {
@@ -165,6 +229,11 @@ export default function Page() {
currentPage,
totalPages,
hasBranches,
canPurchaseBranches,
extraBranches,
branchPricing,
maxBranchQuota,
planBranchLimit,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
@@ -177,6 +246,8 @@ export default function Page() {
!plan.v3Subscription.plan.limits.branches.canExceed;
const canUpgrade =
plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed;
const atBranchLimit = limits.used >= limits.limit;
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
if (!branchableEnvironment) {
return (
@@ -218,7 +289,15 @@ export default function Page() {
</LinkButton>
{limits.isAtLimit ? (
<UpgradePanel limits={limits} canUpgrade={canUpgrade ?? false} />
<UpgradePanel
limits={limits}
canUpgrade={canUpgrade ?? false}
canPurchaseBranches={canPurchaseBranches}
branchPricing={branchPricing}
extraBranches={extraBranches}
maxBranchQuota={maxBranchQuota}
planBranchLimit={planBranchLimit}
/>
) : (
<NewBranchPanel
button={
@@ -230,7 +309,7 @@ export default function Page() {
fullWidth
textAlignLeft
>
New branch
New branch
</Button>
}
parentEnvironment={branchableEnvironment}
@@ -324,7 +403,15 @@ export default function Page() {
isSticky
hiddenButtons={
isSelected ? null : (
<PopoverMenuItem to={path} title="Switch to branch" />
<LinkButton
to={path}
variant="secondary/small"
LeadingIcon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-2"
className="pl-1.5"
>
Switch to branch
</LinkButton>
)
}
popoverContent={
@@ -333,8 +420,8 @@ export default function Page() {
{isSelected ? null : (
<PopoverMenuItem
to={path}
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
icon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
title="Switch to branch"
/>
)}
@@ -376,20 +463,20 @@ export default function Page() {
/>
<circle
className={`fill-none ${
requiresUpgrade ? "stroke-error" : "stroke-success"
atBranchLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
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"
/>
</svg>
</div>
}
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
content={`${Math.round(usageRatio * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
@@ -399,28 +486,36 @@ export default function Page() {
</Header3>
) : (
<div className="flex items-center gap-1">
<Header3>
<Header3 className={atBranchLimit ? "text-error" : undefined}>
You've used {limits.used}/{limits.limit} of your branches
</Header3>
<InfoIconTooltip content="Archived branches don't count towards your limit." />
</div>
)}
{canUpgrade ? (
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
{canPurchaseBranches && branchPricing ? (
<PurchaseBranchesModal
branchPricing={branchPricing}
extraBranches={extraBranches}
activeBranches={limits.used}
maxQuota={maxBranchQuota}
planBranchLimit={planBranchLimit}
/>
)}
) : canUpgrade ? (
<div className="flex items-center gap-3">
<Paragraph variant="small" className="whitespace-nowrap text-text-dimmed">
Upgrade plan for more Preview Branches
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
</div>
) : null}
</div>
</div>
</div>
@@ -434,42 +529,23 @@ export default function Page() {
export function BranchFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const { search, showArchived, page } = BranchesOptions.parse(
Object.fromEntries(searchParams.entries())
);
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
const handleArchivedChange = useCallback((checked: boolean) => {
setSearchParams((s) => {
if (value) {
searchParams.set(filterType, value);
if (checked) {
s.set("showArchived", "true");
} else {
searchParams.delete(filterType);
s.delete("showArchived");
}
searchParams.delete("page");
return searchParams;
s.delete("page");
return s;
});
}, []);
const handleArchivedChange = useCallback((checked: boolean) => {
handleFilterChange("showArchived", checked ? "true" : undefined);
}, []);
const handleSearchChange = useThrottle((value: string) => {
handleFilterChange("search", value.length === 0 ? undefined : value);
}, 300);
return (
<div className="flex w-full gap-2">
<Input
name="search"
placeholder="Search branch name"
icon={MagnifyingGlassIcon}
variant="tertiary"
className="grow"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
<div className="flex w-full items-center justify-between gap-2">
<SearchInput placeholder="Search branch name" resetParams={["page"]} />
<Switch
checked={showArchived ?? false}
onCheckedChange={handleArchivedChange}
@@ -483,15 +559,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 (
<PurchaseBranchesModal
branchPricing={branchPricing}
extraBranches={extraBranches}
activeBranches={limits.used}
maxQuota={maxBranchQuota}
planBranchLimit={planBranchLimit}
triggerButton={
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
variant="primary/small"
shortcut={{ key: "n" }}
>
Purchase more
</Button>
}
/>
);
}
return (
<Dialog>
<DialogTrigger asChild>
@@ -517,18 +625,270 @@ function UpgradePanel({
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 fetcher = useFetcher();
const lastSubmission =
fetcher.data && typeof fetcher.data === "object" && "intent" in fetcher.data
? fetcher.data
: undefined;
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);
useEffect(() => {
setAmountValue(extraBranches);
}, [extraBranches]);
const isLoading = fetcher.state !== "idle";
const [open, setOpen] = useState(false);
useEffect(() => {
const data = fetcher.data;
if (fetcher.state === "idle" && data !== null && typeof data === "object" && "ok" in data && data.ok) {
setOpen(false);
}
}, [fetcher.state, fetcher.data]);
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 extra branches…";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{triggerButton ?? (
<Button variant="primary/small" onClick={() => setOpen(true)}>
{title}
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<fetcher.Form method="post" {...form.props}>
<input type="hidden" name="_formType" value="purchase-branches" />
<div className="flex flex-col gap-4 pt-2">
<div className="flex flex-col gap-1">
<Paragraph variant="small/bright">
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).
</Paragraph>
</div>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor="amount" className="text-text-dimmed">
Total extra branches
</Label>
<InputNumberStepper
{...conform.input(amount, { type: "number" })}
step={branchPricing.stepSize}
min={0}
max={undefined}
value={amountValue}
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>
{amount.error ?? amount.initialError?.[""]?.[0]}
</FormError>
<FormError>{form.error}</FormError>
</InputGroup>
</Fieldset>
{state === "need_to_archive" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
You need to archive{" "}
{formatNumber(activeBranches - (planBranchLimit + amountValue))} more{" "}
{activeBranches - (planBranchLimit + amountValue) === 1 ? "branch" : "branches"}{" "}
before you can reduce to this level.
</Paragraph>
</div>
) : state === "above_quota" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
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.
</Paragraph>
</div>
) : (
<div className="flex flex-col pb-3 tabular-nums">
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(extraBranches)}</span> current
extra
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(extraBranches * pricePerBranch, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({extraBranches} {extraBranches === 1 ? "branch" : "branches"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatNumber(amountValue - extraBranches)}
</Header3>
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatCurrency((amountValue - extraBranches) * pricePerBranch, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({Math.abs(amountValue - extraBranches)}{" "}
{Math.abs(amountValue - extraBranches) === 1 ? "branch" : "branches"} @{" "}
{formatCurrency(pricePerBranch, true)}/mth)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(amountValue * pricePerBranch, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({amountValue} {amountValue === 1 ? "branch" : "branches"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
</div>
)}
</div>
<FormButtons
confirmButton={
state === "above_quota" ? (
<>
<input type="hidden" name="action" value="quota-increase" />
<Button
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
variant="primary/medium"
type="submit"
disabled={isLoading}
>
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
amountValue
)}`}</span>
</Button>
</>
) : state === "decrease" || state === "need_to_archive" ? (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="danger/medium"
type="submit"
disabled={isLoading || state === "need_to_archive"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
extraBranches - amountValue
)} ${extraBranches - amountValue === 1 ? "branch" : "branches"}`}</span>
</Button>
</>
) : (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="primary/medium"
type="submit"
disabled={isLoading || state === "no_change"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
amountValue - extraBranches
)} ${amountValue - extraBranches === 1 ? "branch" : "branches"}`}</span>
</Button>
</>
)
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
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,
@@ -14,7 +14,7 @@ import {
} from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { PageBody } from "~/components/layout/AppLayout";
import { LogsSearchInput } from "~/components/logs/LogsSearchInput";
import { SearchInput } from "~/components/primitives/SearchInput";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
@@ -255,7 +255,7 @@ function FiltersBar({
maxPeriodDays={retentionLimitDays}
labelName="Occurred"
/>
<LogsSearchInput placeholder="Search errors..." />
<SearchInput placeholder="Search errors" />
{hasFilters && (
<Form className="h-6">
<Button
@@ -270,7 +270,7 @@ function FiltersBar({
<>
<LogsTaskFilter possibleTasks={[]} />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<LogsSearchInput placeholder="Search errors..." />
<SearchInput placeholder="Search errors" />
{hasFilters && (
<Form className="h-6">
<Button
@@ -26,7 +26,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Callout } from "~/components/primitives/Callout";
import { LogsTable } from "~/components/logs/LogsTable";
import { LogDetailView } from "~/components/logs/LogDetailView";
import { LogsSearchInput } from "~/components/logs/LogsSearchInput";
import { SearchInput } from "~/components/primitives/SearchInput";
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
@@ -271,7 +271,7 @@ function FiltersBar({
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<LogsLevelFilter />
<LogsSearchInput />
<SearchInput />
{hasFilters && (
<Form className="h-6">
<Button
@@ -288,7 +288,7 @@ function FiltersBar({
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<LogsLevelFilter />
<LogsSearchInput />
<SearchInput />
{hasFilters && (
<Form className="h-6">
<Button
@@ -1,19 +1,23 @@
import { useForm } from "@conform-to/react";
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { EnvelopeIcon, LockOpenIcon, TrashIcon, 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";
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 { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
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";
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,
@@ -26,12 +30,20 @@ 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 { InfoPanel } from "~/components/primitives/InfoPanel";
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";
@@ -46,6 +58,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 [
@@ -89,12 +104,67 @@ 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().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()
.int("Must be a whole 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 org = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (!org) {
return json({ ok: false, error: "Organization not found" } as const);
}
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 json({ ok: true } as const);
}
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
@@ -126,11 +196,24 @@ type Member = UseDataFunctionReturn<typeof loader>["members"][number];
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
export default function Page() {
const { members, invites, limits } = useTypedLoaderData<typeof loader>();
const {
members,
invites,
limits,
canPurchaseSeats,
extraSeats,
seatPricing,
maxSeatQuota,
planSeatLimit,
} = useTypedLoaderData<typeof loader>();
const user = useUser();
const organization = useOrganization();
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;
return (
<PageContainer>
@@ -159,97 +242,142 @@ export default function Page() {
))}
</Property.Table>
</AdminDebugTooltip>
{requiresUpgrade ? (
<SimpleTooltip
button={
<ButtonContent variant="primary/small" LeadingIcon={UserPlusIcon} className="cursor-not-allowed opacity-50">
Invite a team member
</ButtonContent>
}
content="Purchase more seats to invite more team members"
disableHoverableContent
/>
) : (
<LinkButton
to={inviteTeamMemberPath(organization)}
variant="primary/small"
LeadingIcon={UserPlusIcon}
>
Invite a team member
</LinkButton>
)}
</PageAccessories>
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer>
<Header2>
Members{" "}
<span className="font-normal text-text-dimmed">
({limits.used}/{limits.limit})
</span>
</Header2>
<ul className="divide-ui-border mt-3 flex w-full flex-col divide-y border-y border-grid-bright">
{members.map((member) => (
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
<UserAvatar
avatarUrl={member.user.avatarUrl}
name={member.user.name}
className="size-10"
/>
<div className="flex flex-col gap-0.5">
<Header3>
{member.user.name}{" "}
{member.user.id === user.id && <span className="text-text-dimmed">(You)</span>}
</Header3>
<Paragraph variant="small">{member.user.email}</Paragraph>
</div>
<div className="flex grow items-center justify-end gap-4">
<LeaveRemoveButton
userId={user.id}
member={member}
memberCount={members.length}
/>
</div>
</li>
))}
</ul>
{invites.length > 0 && (
<>
<Header2 className="mb-3 mt-4">Pending invites</Header2>
<ul className="flex w-full flex-col divide-y divide-charcoal-800 border-b border-grid-bright">
{invites.map((invite) => (
<li key={invite.id} className="flex items-center gap-4 py-4">
<div className="rounded-md border border-charcoal-750 bg-charcoal-800 p-1.5">
<EnvelopeIcon className="size-7 text-cyan-500" />
</div>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[1fr_auto]">
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="mx-auto max-w-3xl px-4 pb-4 pt-20">
{invites.length > 0 && (
<>
<Header2 className="mb-3 mt-4">Pending invites</Header2>
<ul className="divide-ui-border mb-6 flex w-full flex-col divide-y border-y">
{invites.map((invite) => (
<li key={invite.id} className="flex items-center gap-4 py-4">
<div className="rounded-md border border-charcoal-750 bg-charcoal-800 p-1.5">
<EnvelopeIcon className="size-7 text-text-dimmed" />
</div>
<div className="flex flex-col gap-0.5">
<Header3>{invite.email}</Header3>
<Paragraph variant="small">
Invite sent {<DateTime date={invite.updatedAt} />}
</Paragraph>
</div>
<div className="flex grow items-center justify-end gap-x-2">
<ResendButton invite={invite} />
<RevokeButton invite={invite} />
</div>
</li>
))}
</ul>
</>
)}
<Header2>Active team members</Header2>
<ul className="divide-ui-border mb-8 mt-3 flex w-full flex-col divide-y border-y border-grid-bright">
{members.map((member) => (
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
<UserAvatar
avatarUrl={member.user.avatarUrl}
name={member.user.name}
className="size-10"
/>
<div className="flex flex-col gap-0.5">
<Header3>{invite.email}</Header3>
<Paragraph variant="small">
Invite sent {<DateTime date={invite.updatedAt} />}
</Paragraph>
<Header3>
{member.user.name}{" "}
{member.user.id === user.id && (
<span className="text-text-dimmed">(You)</span>
)}
</Header3>
<Paragraph variant="small">{member.user.email}</Paragraph>
</div>
<div className="flex grow items-center justify-end gap-x-2">
<ResendButton invite={invite} />
<RevokeButton invite={invite} />
<div className="flex grow items-center justify-end gap-4">
<LeaveRemoveButton
userId={user.id}
member={member}
memberCount={members.length}
/>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
accessory={
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
<div className="flex h-fit w-full items-center gap-3 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${requiresUpgrade ? "stroke-error" : "stroke-success"}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${usageRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round(usageRatio * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
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."}
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your seats
</Header3>
)}
{canPurchaseSeats && seatPricing ? (
<PurchaseSeatsModal
seatPricing={seatPricing}
extraSeats={extraSeats}
usedSeats={limits.used}
maxQuota={maxSeatQuota}
planSeatLimit={planSeatLimit}
/>
) : canUpgrade ? (
<LinkButton to={v3BillingPath(organization)} variant="primary/small">
Upgrade
</LinkButton>
}
panelClassName="mt-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="mt-4 flex justify-end">
<LinkButton
to={inviteTeamMemberPath(organization)}
variant={"secondary/small"}
LeadingIcon={UserPlusIcon}
>
Invite a team member
</LinkButton>
) : null}
</div>
)}
</MainHorizontallyCenteredContainer>
</div>
</div>
</PageBody>
</PageContainer>
);
@@ -275,6 +403,7 @@ function LeaveRemoveButton({
Leave team
</ButtonContent>
}
disableHoverableContent
content="An organization requires at least 1 team member"
/>
);
@@ -332,7 +461,7 @@ function LeaveTeamModal({
return (
<Alert open={open} onOpenChange={(o) => setOpen(o)}>
<AlertTrigger asChild>
<Button variant="tertiary/small">{buttonText}</Button>
<Button variant="secondary/small">{buttonText}</Button>
</AlertTrigger>
<AlertContent>
<AlertHeader>
@@ -341,7 +470,7 @@ function LeaveTeamModal({
</AlertHeader>
<AlertFooter>
<AlertCancel asChild>
<Button variant="tertiary/small">Cancel</Button>
<Button variant="secondary/small">Cancel</Button>
</AlertCancel>
<Form method="post" {...form.props} onSubmit={() => setOpen(false)}>
<input type="hidden" value={member.id} name="memberId" />
@@ -355,12 +484,61 @@ function LeaveTeamModal({
);
}
const RESEND_COOLDOWN_SECONDS = 30;
function initialCooldown(updatedAt: Date | string): number {
const elapsed = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 1000);
const remaining = RESEND_COOLDOWN_SECONDS - elapsed;
return remaining > 0 ? remaining : 0;
}
function ResendButton({ invite }: { invite: Invite }) {
const navigation = useNavigation();
const isSubmitting =
navigation.state === "submitting" &&
navigation.formAction === resendInvitePath() &&
navigation.formData?.get("inviteId") === invite.id;
const prevSubmitting = useRef(false);
const [cooldown, setCooldown] = useState(() => initialCooldown(invite.updatedAt));
const intervalRef = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {
if (prevSubmitting.current && !isSubmitting) {
setCooldown(RESEND_COOLDOWN_SECONDS);
}
prevSubmitting.current = isSubmitting;
}, [isSubmitting]);
const cooldownActive = cooldown > 0;
useEffect(() => {
if (!cooldownActive) return;
intervalRef.current = setInterval(() => {
setCooldown((c) => {
if (c <= 1) {
clearInterval(intervalRef.current);
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(intervalRef.current);
}, [cooldownActive]);
const isDisabled = isSubmitting || cooldown > 0;
return (
<Form method="post" action={resendInvitePath()} className="flex">
<input type="hidden" value={invite.id} name="inviteId" />
<Button type="submit" variant="tertiary/small">
Resend invite
<Button type="submit" variant="secondary/small" disabled={isDisabled}>
{isSubmitting ? (
"Sending…"
) : cooldown > 0 ? (
<span className="tabular-nums">{`Sent resend in ${cooldown}s`}</span>
) : (
"Resend invite"
)}
</Button>
</Form>
);
@@ -373,12 +551,285 @@ function RevokeButton({ invite }: { invite: Invite }) {
<Form method="post" action={revokeInvitePath()} className="flex">
<input type="hidden" value={invite.id} name="inviteId" />
<input type="hidden" value={organization.slug} name="slug" />
<Button
type="submit"
variant="danger/small"
LeadingIcon={TrashIcon}
leadingIconClassName="text-white"
<SimpleTooltip
button={
<Button
type="submit"
variant="danger/small"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-white"
aria-label="Revoke invite"
/>
}
content="Revoke invite"
disableHoverableContent
asChild
/>
</Form>
);
}
export function PurchaseSeatsModal({
seatPricing,
extraSeats,
usedSeats,
maxQuota,
planSeatLimit,
triggerButton,
}: {
seatPricing: {
stepSize: number;
centsPerStep: number;
};
extraSeats: number;
usedSeats: number;
maxQuota: number;
planSeatLimit: number;
triggerButton?: React.ReactElement;
}) {
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: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: PurchaseSchema });
},
shouldRevalidate: "onSubmit",
});
const [amountValue, setAmountValue] = useState(extraSeats);
useEffect(() => {
setAmountValue(extraSeats);
}, [extraSeats]);
const isLoading = fetcher.state !== "idle";
const [open, setOpen] = useState(false);
useEffect(() => {
const data = fetcher.data;
if (
fetcher.state === "idle" &&
data !== null &&
typeof data === "object" &&
"ok" in data &&
data.ok
) {
setOpen(false);
}
}, [fetcher.state, fetcher.data]);
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{triggerButton ?? (
<Button variant="primary/small" onClick={() => setOpen(true)}>
{title}
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<fetcher.Form method="post" action={organizationTeamPath(organization)} {...form.props}>
<input type="hidden" name="_formType" value="purchase-seats" />
<div className="flex flex-col gap-4 pt-2">
<div className="flex flex-col gap-1">
<Paragraph variant="small/bright">
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).
</Paragraph>
</div>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor="amount" className="text-text-dimmed">
Total extra seats
</Label>
<InputNumberStepper
{...conform.input(amount, { type: "number" })}
step={seatPricing.stepSize}
min={0}
max={undefined}
value={amountValue}
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>
{amount.error ?? amount.initialError?.[""]?.[0]}
</FormError>
<FormError>{form.error}</FormError>
</InputGroup>
</Fieldset>
{state === "need_to_remove_members" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
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.
</Paragraph>
</div>
) : state === "above_quota" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
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.
</Paragraph>
</div>
) : (
<div className="flex flex-col pb-3 tabular-nums">
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(extraSeats)}</span> current
extra
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(extraSeats * pricePerSeat, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({extraSeats} {extraSeats === 1 ? "seat" : "seats"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatNumber(amountValue - extraSeats)}
</Header3>
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatCurrency((amountValue - extraSeats) * pricePerSeat, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({Math.abs(amountValue - extraSeats)}{" "}
{Math.abs(amountValue - extraSeats) === 1 ? "seat" : "seats"} @{" "}
{formatCurrency(pricePerSeat, true)}/mth)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(amountValue * pricePerSeat, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({amountValue} {amountValue === 1 ? "seat" : "seats"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
</div>
)}
</div>
<FormButtons
confirmButton={
state === "above_quota" ? (
<>
<input type="hidden" name="action" value="quota-increase" />
<Button
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
variant="primary/medium"
type="submit"
disabled={isLoading}
>
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
amountValue
)}`}</span>
</Button>
</>
) : state === "decrease" || state === "need_to_remove_members" ? (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="danger/medium"
type="submit"
disabled={isLoading || state === "need_to_remove_members"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
extraSeats - amountValue
)} ${extraSeats - amountValue === 1 ? "seat" : "seats"}`}</span>
</Button>
</>
) : (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="primary/medium"
type="submit"
disabled={isLoading || state === "no_change"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
amountValue - extraSeats
)} ${amountValue - extraSeats === 1 ? "seat" : "seats"}`}</span>
</Button>
</>
)
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
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";
}
@@ -120,7 +120,7 @@ export function ArchiveButton({
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
+32 -3
View File
@@ -34,10 +34,7 @@ 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 +406,38 @@ 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;
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;
@@ -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,
@@ -0,0 +1,100 @@
import { BaseService } from "./baseService.server";
import { tryCatch } from "@trigger.dev/core/utils";
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<Result> {
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);
}
}
}
}
@@ -0,0 +1,100 @@
import { BaseService } from "./baseService.server";
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";
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<Result> {
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);
}
}
}
}
+1 -1
View File
@@ -122,7 +122,7 @@
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.23",
"@trigger.dev/platform": "1.0.24",
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
+13 -13
View File
@@ -501,8 +501,8 @@ importers:
specifier: workspace:*
version: link:../../internal-packages/otlp-importer
'@trigger.dev/platform':
specifier: 1.0.23
version: 1.0.23
specifier: 1.0.24
version: 1.0.24
'@trigger.dev/redis-worker':
specifier: workspace:*
version: link:../../packages/redis-worker
@@ -1104,7 +1104,7 @@ importers:
version: 18.3.1
react-email:
specifier: ^2.1.1
version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0)
version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0)
resend:
specifier: ^3.2.0
version: 3.2.0
@@ -10514,8 +10514,8 @@ packages:
react: ^18.2.0
react-dom: 18.2.0
'@trigger.dev/platform@1.0.23':
resolution: {integrity: sha512-/fHMOKHdqRv6t70h0weUorOeVOkX+8WGWwPlzdq+uGDqkf8ZrcwBDuBSyoG9KkyvIsA8Tw64zVbWK94CbVlznw==}
'@trigger.dev/platform@1.0.24':
resolution: {integrity: sha512-dg9/QWyNBCctbGhzr9U2vImUdJNR+2FqTHVTJfgrSq12BIBpwAfUiSfel1S/beEGltBKhi7KXkBAsk+9cFPPzQ==}
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
@@ -23155,7 +23155,7 @@ snapshots:
'@epic-web/test-server@0.1.0(bufferutil@4.0.9)':
dependencies:
'@hono/node-server': 1.12.2(hono@4.5.11)
'@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)
'@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)
'@open-draft/deferred-promise': 2.2.0
'@types/ws': 8.5.12
hono: 4.5.11
@@ -23910,7 +23910,7 @@ snapshots:
dependencies:
hono: 4.11.8
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)':
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)':
dependencies:
'@hono/node-server': 1.12.2(hono@4.5.11)
ws: 8.18.3(bufferutil@4.0.9)
@@ -30720,7 +30720,7 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@trigger.dev/platform@1.0.23':
'@trigger.dev/platform@1.0.24':
dependencies:
zod: 3.23.8
@@ -39223,7 +39223,7 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0):
react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0):
dependencies:
'@babel/parser': 7.24.1
'@radix-ui/colors': 1.0.1
@@ -39260,8 +39260,8 @@ snapshots:
react: 18.3.1
react-dom: 18.2.0(react@18.3.1)
shelljs: 0.8.5
socket.io: 4.7.3(bufferutil@4.0.9)
socket.io-client: 4.7.3(bufferutil@4.0.9)
socket.io: 4.7.3
socket.io-client: 4.7.3
sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
source-map-js: 1.0.2
stacktrace-parser: 0.1.10
@@ -40461,7 +40461,7 @@ snapshots:
- supports-color
- utf-8-validate
socket.io-client@4.7.3(bufferutil@4.0.9):
socket.io-client@4.7.3:
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.7(supports-color@10.0.0)
@@ -40490,7 +40490,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
socket.io@4.7.3(bufferutil@4.0.9):
socket.io@4.7.3:
dependencies:
accepts: 1.3.8
base64id: 2.0.0