Skip to content

Commit fea8ae4

Browse files
authored
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
1 parent 21fdb52 commit fea8ae4

File tree

15 files changed

+1356
-233
lines changed

15 files changed

+1356
-233
lines changed

apps/webapp/app/components/logs/LogsSearchInput.tsx renamed to apps/webapp/app/components/primitives/SearchInput.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,25 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey";
66
import { useSearchParams } from "~/hooks/useSearchParam";
77
import { cn } from "~/utils/cn";
88

9-
export type LogsSearchInputProps = {
9+
export type SearchInputProps = {
1010
placeholder?: string;
11+
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
12+
resetParams?: string[];
1113
};
1214

13-
export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchInputProps) {
15+
export function SearchInput({
16+
placeholder = "Search logs…",
17+
resetParams = ["cursor", "direction"],
18+
}: SearchInputProps) {
1419
const inputRef = useRef<HTMLInputElement>(null);
1520

1621
const { value, replace, del } = useSearchParams();
1722

18-
// Get initial search value from URL
1923
const initialSearch = value("search") ?? "";
2024

2125
const [text, setText] = useState(initialSearch);
2226
const [isFocused, setIsFocused] = useState(false);
2327

24-
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
2528
useEffect(() => {
2629
const urlSearch = value("search") ?? "";
2730
if (urlSearch !== text && !isFocused) {
@@ -30,21 +33,22 @@ export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchIn
3033
}, [value, text, isFocused]);
3134

3235
const handleSubmit = useCallback(() => {
36+
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
3337
if (text.trim()) {
34-
replace({ search: text.trim() });
38+
replace({ search: text.trim(), ...resetValues });
3539
} else {
36-
del("search");
40+
del(["search", ...resetParams]);
3741
}
38-
}, [text, replace, del]);
42+
}, [text, replace, del, resetParams]);
3943

4044
const handleClear = useCallback(
4145
(e: React.MouseEvent<HTMLButtonElement>) => {
4246
e.preventDefault();
4347
e.stopPropagation();
4448
setText("");
45-
del(["search", "cursor", "direction"]);
49+
del(["search", ...resetParams]);
4650
},
47-
[del]
51+
[del, resetParams]
4852
);
4953

5054
return (
@@ -82,12 +86,12 @@ export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchIn
8286
icon={<MagnifyingGlassIcon className="size-4" />}
8387
accessory={
8488
text.length > 0 ? (
85-
<div className="-mr-1 flex items-center gap-1">
86-
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
89+
<div className="-mr-1 flex items-center gap-1.5">
90+
<ShortcutKey shortcut={{ key: "enter" }} variant="medium" className="border-none" />
8791
<button
8892
type="button"
8993
onClick={handleClear}
90-
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"
94+
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"
9195
title="Clear search"
9296
>
9397
<XMarkIcon className="size-3" />

apps/webapp/app/presenters/TeamPresenter.server.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getTeamMembersAndInvites } from "~/models/member.server";
2-
import { getLimit } from "~/services/platform.v3.server";
2+
import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server";
33
import { BasePresenter } from "./v3/basePresenter.server";
44

55
export class TeamPresenter extends BasePresenter {
@@ -13,14 +13,31 @@ export class TeamPresenter extends BasePresenter {
1313
return;
1414
}
1515

16-
const limit = await getLimit(organizationId, "teamMembers", 100_000_000);
16+
const [baseLimit, currentPlan, plans] = await Promise.all([
17+
getLimit(organizationId, "teamMembers", 100_000_000),
18+
getCurrentPlan(organizationId),
19+
getPlans(),
20+
]);
21+
22+
const canPurchaseSeats =
23+
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
24+
const extraSeats = currentPlan?.v3Subscription?.addOns?.seats?.purchased ?? 0;
25+
const maxSeatQuota = currentPlan?.v3Subscription?.addOns?.seats?.quota ?? 0;
26+
const planSeatLimit = currentPlan?.v3Subscription?.plan?.limits.teamMembers.number ?? 0;
27+
const seatPricing = plans?.addOnPricing.seats ?? null;
28+
const limit = baseLimit + extraSeats;
1729

1830
return {
1931
...result,
2032
limits: {
2133
used: result.members.length + result.invites.length,
2234
limit,
2335
},
36+
canPurchaseSeats,
37+
extraSeats,
38+
seatPricing,
39+
maxSeatQuota,
40+
planSeatLimit,
2441
};
2542
}
2643
}

apps/webapp/app/presenters/v3/BranchesPresenter.server.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type Prisma, type PrismaClient, prisma } from "~/db.server";
44
import { type Project } from "~/models/project.server";
55
import { type User } from "~/models/user.server";
66
import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
7+
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
78
import { checkBranchLimit } from "~/services/upsertBranch.server";
89

910
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
@@ -110,6 +111,11 @@ export class BranchesPresenter {
110111
limit: 0,
111112
isAtLimit: true,
112113
},
114+
canPurchaseBranches: false,
115+
extraBranches: 0,
116+
branchPricing: null,
117+
maxBranchQuota: 0,
118+
planBranchLimit: 0,
113119
};
114120
}
115121

@@ -131,6 +137,18 @@ export class BranchesPresenter {
131137
// Limits
132138
const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id);
133139

140+
const [currentPlan, plans] = await Promise.all([
141+
getCurrentPlan(project.organizationId),
142+
getPlans(),
143+
]);
144+
145+
const canPurchaseBranches =
146+
currentPlan?.v3Subscription?.plan?.limits.branches.canExceed === true;
147+
const extraBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
148+
const maxBranchQuota = currentPlan?.v3Subscription?.addOns?.branches?.quota ?? 0;
149+
const planBranchLimit = currentPlan?.v3Subscription?.plan?.limits.branches.number ?? 0;
150+
const branchPricing = plans?.addOnPricing.branches ?? null;
151+
134152
const branches = await this.#prismaClient.runtimeEnvironment.findMany({
135153
select: {
136154
id: true,
@@ -191,6 +209,11 @@ export class BranchesPresenter {
191209
}),
192210
hasFilters,
193211
limits,
212+
canPurchaseBranches,
213+
extraBranches,
214+
branchPricing,
215+
maxBranchQuota,
216+
planBranchLimit,
194217
};
195218
}
196219
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
3-
import { EnvelopeIcon, LockOpenIcon, UserPlusIcon } from "@heroicons/react/20/solid";
3+
import {
4+
ArrowUpCircleIcon,
5+
EnvelopeIcon,
6+
LockOpenIcon,
7+
UserPlusIcon,
8+
} from "@heroicons/react/20/solid";
49
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
510
import { json } from "@remix-run/node";
611
import { Form, useActionData } from "@remix-run/react";
@@ -29,6 +34,7 @@ import { TeamPresenter } from "~/presenters/TeamPresenter.server";
2934
import { scheduleEmail } from "~/services/email.server";
3035
import { requireUserId } from "~/services/session.server";
3136
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
37+
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
3238

3339
const Params = z.object({
3440
organizationSlug: z.string(),
@@ -122,7 +128,8 @@ export const action: ActionFunction = async ({ request, params }) => {
122128
};
123129

124130
export default function Page() {
125-
const { limits } = useTypedLoaderData<typeof loader>();
131+
const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } =
132+
useTypedLoaderData<typeof loader>();
126133
const [total, setTotal] = useState(limits.used);
127134
const organization = useOrganization();
128135
const lastSubmission = useActionData();
@@ -150,25 +157,54 @@ export default function Page() {
150157
title="Invite team members"
151158
description={`Invite new team members to ${organization.title}.`}
152159
/>
153-
{total > limits.limit && (
154-
<InfoPanel
155-
variant="upgrade"
156-
icon={LockOpenIcon}
157-
iconClassName="text-indigo-500"
158-
title="Unlock more team members"
159-
accessory={
160-
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
161-
Upgrade
162-
</LinkButton>
163-
}
164-
panelClassName="mb-4"
165-
>
166-
<Paragraph variant="small">
167-
You've used all {limits.limit} of your available team members. Upgrade your plan to
168-
add more.
169-
</Paragraph>
170-
</InfoPanel>
171-
)}
160+
{total > limits.limit &&
161+
(canPurchaseSeats && seatPricing ? (
162+
<InfoPanel
163+
variant="upgrade"
164+
icon={LockOpenIcon}
165+
iconClassName="text-indigo-500"
166+
title="Need more seats?"
167+
accessory={
168+
<PurchaseSeatsModal
169+
seatPricing={seatPricing}
170+
extraSeats={extraSeats}
171+
usedSeats={limits.used}
172+
maxQuota={maxSeatQuota}
173+
planSeatLimit={planSeatLimit}
174+
triggerButton={<Button variant="primary/small">Purchase more seats…</Button>}
175+
/>
176+
}
177+
panelClassName="mb-4"
178+
>
179+
<Paragraph variant="small">
180+
You've used all {limits.limit} of your available team members. Purchase extra seats
181+
to add more.
182+
</Paragraph>
183+
</InfoPanel>
184+
) : (
185+
<InfoPanel
186+
variant="upgrade"
187+
icon={LockOpenIcon}
188+
iconClassName="text-indigo-500"
189+
title="Unlock more team members"
190+
accessory={
191+
<LinkButton
192+
to={v3BillingPath(organization)}
193+
variant="secondary/small"
194+
LeadingIcon={ArrowUpCircleIcon}
195+
leadingIconClassName="text-indigo-500"
196+
>
197+
Upgrade
198+
</LinkButton>
199+
}
200+
panelClassName="mb-4"
201+
>
202+
<Paragraph variant="small">
203+
You've used all {limits.limit} of your available team members. Upgrade your plan to
204+
add more.
205+
</Paragraph>
206+
</InfoPanel>
207+
))}
172208
<Form method="post" {...form.props}>
173209
<Fieldset>
174210
<InputGroup>

0 commit comments

Comments
 (0)