Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aaf5045
Makes LogsSearchInput more generic as it’s being used in multiple places
samejr Mar 6, 2026
d95756d
Nicer Switch branch buttons in the table
samejr Mar 6, 2026
174e289
Purchase new preview branches works with modal
samejr Mar 9, 2026
0098b51
Show the extra branches purchased in the footer UI
samejr Mar 10, 2026
3317325
Button style update
samejr Mar 10, 2026
d9f210b
Modal and text improvements
samejr Mar 10, 2026
cee54f5
Update team member layout and show member count footer bar
samejr Mar 10, 2026
c76086a
Add a cooldown on the Resend invite button
samejr Mar 10, 2026
2e28389
Style improvements
samejr Mar 10, 2026
56f0c74
Implements new seats purchase logic and UI
samejr Mar 10, 2026
a03061e
Switch PurchaseSeatsModal to useFetcher for scoped loading state, cro…
samejr Mar 10, 2026
f6039d0
Refactor PurchaseSeatsModal to use useFetcher for isolated state and …
samejr Mar 10, 2026
ae9aa41
Improved some copy
samejr Mar 10, 2026
0bb5fc1
Show a tooltip on the invite button if you don’t have enough seats
samejr Mar 10, 2026
cf63643
Dependency array fix suggestion from code rabbit
samejr Mar 10, 2026
b6be53b
Code rabbit fix
samejr Mar 10, 2026
a890df5
Add aria-label
samejr Mar 10, 2026
b9bcbbc
Import fix (code rabbit suggestion)
samejr Mar 10, 2026
90d9f9a
Type fix (code rabbit)
samejr Mar 10, 2026
b6bcd0e
Merge remote-tracking branch 'origin/main' into feat(webapp)-self-ser…
samejr Mar 10, 2026
bcbb07c
Minor code rabbit suggested fixes
samejr Mar 10, 2026
c4f83c7
New package version
samejr Mar 12, 2026
556eedf
Adds int validation
samejr Mar 12, 2026
d25900d
Update import
samejr Mar 12, 2026
129b29c
Refactor to use useFetcher instead
samejr Mar 12, 2026
0e3c379
Make the branches progress bar red if you have no branches available
samejr Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 (
Expand Down Expand Up @@ -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" />
Expand Down
21 changes: 19 additions & 2 deletions apps/webapp/app/presenters/TeamPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,14 +13,31 @@ 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,
limits: {
used: result.members.length + result.invites.length,
limit,
},
canPurchaseSeats,
extraSeats,
seatPricing,
maxSeatQuota,
planSeatLimit,
};
}
}
23 changes: 23 additions & 0 deletions apps/webapp/app/presenters/v3/BranchesPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]>>;
Expand Down Expand Up @@ -110,6 +111,11 @@ export class BranchesPresenter {
limit: 0,
isAtLimit: true,
},
canPurchaseBranches: false,
extraBranches: 0,
branchPricing: null,
maxBranchQuota: 0,
planBranchLimit: 0,
};
}

Expand All @@ -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,
Expand Down Expand Up @@ -191,6 +209,11 @@ export class BranchesPresenter {
}),
hasFilters,
limits,
canPurchaseBranches,
extraBranches,
branchPricing,
maxBranchQuota,
planBranchLimit,
};
}
}
Expand Down
78 changes: 57 additions & 21 deletions apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading