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:
+16
-12
@@ -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>
|
||||
|
||||
+430
-70
@@ -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,
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+13
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user