Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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