874 lines
33 KiB
TypeScript
874 lines
33 KiB
TypeScript
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||
import { parseWithZod } from "@conform-to/zod";
|
||
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 { useFetcher, useSearchParams } from "@remix-run/react";
|
||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||
import { 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";
|
||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||
import { Badge } from "~/components/primitives/Badge";
|
||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||
import { DateTime } from "~/components/primitives/DateTime";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
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 { Header3 } from "~/components/primitives/Headers";
|
||
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,
|
||
TableBlankRow,
|
||
TableBody,
|
||
TableCell,
|
||
TableCellMenu,
|
||
TableHeader,
|
||
TableHeaderCell,
|
||
TableRow,
|
||
} from "~/components/primitives/Table";
|
||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||
import { useOrganization } from "~/hooks/useOrganizations";
|
||
import { useProject } from "~/hooks/useProject";
|
||
|
||
import { findProjectBySlug } from "~/models/project.server";
|
||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
|
||
import { logger } from "~/services/logger.server";
|
||
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
|
||
import { requireUserId } from "~/services/session.server";
|
||
import { cn } from "~/utils/cn";
|
||
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 { NewBranchPanel } from "~/routes/resources.branches.create";
|
||
import { BranchesOptions } from "~/utils/branches";
|
||
import { IconArrowBearRight2 } from "@tabler/icons-react";
|
||
|
||
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 loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const userId = await requireUserId(request);
|
||
const { projectParam } = ProjectParamSchema.parse(params);
|
||
|
||
const searchParams = new URL(request.url).searchParams;
|
||
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
|
||
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
|
||
|
||
try {
|
||
const presenter = new BranchesPresenter();
|
||
const result = await presenter.call({
|
||
userId,
|
||
projectSlug: projectParam,
|
||
env: "preview",
|
||
...options,
|
||
});
|
||
|
||
return typedjson(result);
|
||
} catch (error) {
|
||
logger.error("Error loading preview branches page", { error });
|
||
throw new Response(undefined, {
|
||
status: 400,
|
||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||
});
|
||
}
|
||
};
|
||
|
||
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 await redirectWithErrorMessage(redirectPath, request, "Project not found");
|
||
}
|
||
|
||
const currentPlan = await getCurrentPlan(project.organizationId);
|
||
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
|
||
if (purchaseBlockReason === "plan_unavailable") {
|
||
return json(
|
||
{ ok: false, error: "Unable to verify billing status. Please try again." } as const,
|
||
{ status: 503 }
|
||
);
|
||
}
|
||
if (purchaseBlockReason === "managed_billing") {
|
||
return json({ ok: false, error: "Contact us to request more branches." } as const, {
|
||
status: 403,
|
||
});
|
||
}
|
||
|
||
const submission = parseWithZod(formData, { schema: PurchaseSchema });
|
||
|
||
if (submission.status !== "success") {
|
||
return json(submission.reply());
|
||
}
|
||
|
||
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) {
|
||
return json(
|
||
submission.reply({
|
||
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
|
||
})
|
||
);
|
||
}
|
||
|
||
if (!result.success) {
|
||
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
|
||
}
|
||
|
||
return json({ ok: true } as const);
|
||
}
|
||
|
||
// Branch creation is handled by the `resources.branches.create` resource
|
||
// route; this action only services the purchase flow above.
|
||
return json({ ok: false, error: "Unsupported action" } as const, { status: 400 });
|
||
}
|
||
|
||
export default function Page() {
|
||
const {
|
||
branchableEnvironment,
|
||
branches,
|
||
hasFilters: _hasFilters,
|
||
limits,
|
||
currentPage,
|
||
totalPages,
|
||
hasBranches,
|
||
canPurchaseBranches,
|
||
extraBranches,
|
||
branchPricing,
|
||
maxBranchQuota,
|
||
planBranchLimit,
|
||
} = useTypedLoaderData<typeof loader>();
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
|
||
const plan = useCurrentPlan();
|
||
const showSelfServe = useShowSelfServe();
|
||
const requiresUpgrade =
|
||
plan?.v3Subscription?.plan &&
|
||
limits.used >= plan.v3Subscription.plan.limits.branches.number &&
|
||
!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 (
|
||
<PageContainer>
|
||
<NavBar>
|
||
<PageTitle title={<V4Title>Preview branches</V4Title>} />
|
||
</NavBar>
|
||
<PageBody>
|
||
<MainCenteredContainer className="max-w-md">
|
||
<BranchesNoBranchableEnvironment showSelfServe={showSelfServe} />
|
||
</MainCenteredContainer>
|
||
</PageBody>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PageContainer>
|
||
<NavBar>
|
||
<PageTitle title={<V4Title>Preview branches</V4Title>} />
|
||
<PageAccessories>
|
||
<AdminDebugTooltip>
|
||
<Property.Table>
|
||
{branches.map((branch) => (
|
||
<Property.Item key={branch.id}>
|
||
<Property.Label>{branch.branchName}</Property.Label>
|
||
<Property.Value>{branch.id}</Property.Value>
|
||
</Property.Item>
|
||
))}
|
||
</Property.Table>
|
||
</AdminDebugTooltip>
|
||
|
||
<LinkButton
|
||
variant={"docs/small"}
|
||
LeadingIcon={BookOpenIcon}
|
||
to={docsPath("deployment/preview-branches")}
|
||
>
|
||
Branches docs
|
||
</LinkButton>
|
||
|
||
{limits.isAtLimit ? (
|
||
<UpgradePanel
|
||
limits={limits}
|
||
canUpgrade={canUpgrade ?? false}
|
||
canPurchaseBranches={canPurchaseBranches}
|
||
branchPricing={branchPricing}
|
||
extraBranches={extraBranches}
|
||
maxBranchQuota={maxBranchQuota}
|
||
planBranchLimit={planBranchLimit}
|
||
/>
|
||
) : (
|
||
<NewBranchPanel
|
||
button={
|
||
<Button
|
||
variant="primary/small"
|
||
shortcut={{ key: "n" }}
|
||
LeadingIcon={PlusIcon}
|
||
leadingIconClassName="text-white"
|
||
fullWidth
|
||
textAlignLeft
|
||
>
|
||
New branch…
|
||
</Button>
|
||
}
|
||
env="preview"
|
||
/>
|
||
)}
|
||
</PageAccessories>
|
||
</NavBar>
|
||
<PageBody scrollable={false}>
|
||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||
{!hasBranches ? (
|
||
<MainCenteredContainer className="max-w-md">
|
||
<BranchesNoBranches
|
||
env="preview"
|
||
limits={limits}
|
||
canUpgrade={canUpgrade ?? false}
|
||
showSelfServe={showSelfServe}
|
||
/>
|
||
</MainCenteredContainer>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center justify-between gap-x-1.5 p-2">
|
||
<BranchFilters />
|
||
<PaginationControls
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
showPageNumbers={false}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHeaderCell>Branch</TableHeaderCell>
|
||
<TableHeaderCell>Created</TableHeaderCell>
|
||
<TableHeaderCell>Git</TableHeaderCell>
|
||
<TableHeaderCell>Archived</TableHeaderCell>
|
||
<TableHeaderCell>
|
||
<span className="sr-only">Actions</span>
|
||
</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{branches.length === 0 ? (
|
||
<TableBlankRow colSpan={5}>
|
||
<Paragraph>There are no matches for your filters</Paragraph>
|
||
</TableBlankRow>
|
||
) : (
|
||
branches.map((branch) => {
|
||
const path = branchesPath(organization, project, branch);
|
||
const cellClass = branch.archivedAt ? "opacity-50" : "";
|
||
const isSelected = branch.id === environment.id;
|
||
|
||
return (
|
||
<TableRow key={branch.id}>
|
||
<TableCell isTabbableCell className={cellClass}>
|
||
<div className="flex items-center gap-1">
|
||
<BranchEnvironmentIconSmall
|
||
className={cn("size-4", isSelected && "text-preview")}
|
||
/>
|
||
<CopyableText
|
||
value={branch.branchName ?? ""}
|
||
className={cn(isSelected && "text-preview")}
|
||
/>
|
||
{isSelected && <Badge variant="extra-small">Current</Badge>}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className={cellClass}>
|
||
<DateTime date={branch.createdAt} />
|
||
</TableCell>
|
||
<TableCell className={cellClass}>
|
||
<div className="-ml-1 flex items-center">
|
||
<GitMetadata git={branch.git} />
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className={cellClass}>
|
||
{branch.archivedAt ? (
|
||
<CheckIcon className="size-4 text-text-dimmed" />
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
<TableCellMenu
|
||
className="pl-32"
|
||
isSticky
|
||
hiddenButtons={
|
||
isSelected ? null : (
|
||
<LinkButton
|
||
to={path}
|
||
variant="secondary/small"
|
||
LeadingIcon={IconArrowBearRight2}
|
||
leadingIconClassName="text-blue-500 -mr-2"
|
||
className="pl-1.5"
|
||
>
|
||
Switch to branch
|
||
</LinkButton>
|
||
)
|
||
}
|
||
popoverContent={
|
||
!isSelected || !branch.archivedAt ? (
|
||
<>
|
||
{isSelected ? null : (
|
||
<PopoverMenuItem
|
||
to={path}
|
||
icon={IconArrowBearRight2}
|
||
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
|
||
title="Switch to branch"
|
||
/>
|
||
)}
|
||
{!branch.archivedAt ? (
|
||
<ArchiveButton environment={branch} />
|
||
) : null}
|
||
</>
|
||
) : null
|
||
}
|
||
/>
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="flex w-full items-start justify-between">
|
||
<div className="flex h-fit w-full items-center gap-4 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 ${
|
||
atBranchLimit ? "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 branches. Archive one or upgrade your
|
||
plan to enable more.
|
||
</Header3>
|
||
) : (
|
||
<div className="flex items-center gap-1">
|
||
<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>
|
||
)}
|
||
|
||
{canPurchaseBranches && branchPricing ? (
|
||
<PurchaseBranchesModal
|
||
branchPricing={branchPricing}
|
||
extraBranches={extraBranches}
|
||
activeBranches={limits.used}
|
||
maxQuota={maxBranchQuota}
|
||
planBranchLimit={planBranchLimit}
|
||
/>
|
||
) : canUpgrade ? (
|
||
showSelfServe ? (
|
||
<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>
|
||
) : (
|
||
<Feedback
|
||
defaultValue="enterprise"
|
||
button={<Button variant="secondary/small">Request more</Button>}
|
||
/>
|
||
)
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</PageBody>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
export function BranchFilters() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
|
||
|
||
const handleArchivedChange = useCallback((checked: boolean) => {
|
||
setSearchParams((s) => {
|
||
if (checked) {
|
||
s.set("showArchived", "true");
|
||
} else {
|
||
s.delete("showArchived");
|
||
}
|
||
s.delete("page");
|
||
return s;
|
||
});
|
||
}, []);
|
||
|
||
return (
|
||
<div className="flex w-full items-center justify-between gap-2">
|
||
<SearchInput placeholder="Search branch name…" resetParams={["page"]} />
|
||
<Switch
|
||
checked={showArchived ?? false}
|
||
onCheckedChange={handleArchivedChange}
|
||
label="Show archived"
|
||
variant="secondary/small"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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();
|
||
const showSelfServe = useShowSelfServe();
|
||
|
||
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>
|
||
<Button
|
||
LeadingIcon={PlusIcon}
|
||
leadingIconClassName="text-white"
|
||
variant="primary/small"
|
||
shortcut={{ key: "n" }}
|
||
>
|
||
New branch
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>You've exceeded your limit</DialogHeader>
|
||
<div className="mt-2">
|
||
<Paragraph spacing>
|
||
You've used {limits.used}/{limits.limit} of your branches.
|
||
</Paragraph>
|
||
<Paragraph>You can archive one or upgrade your plan for more.</Paragraph>
|
||
</div>
|
||
<DialogFooter>
|
||
{canUpgrade ? (
|
||
showSelfServe ? (
|
||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||
Upgrade
|
||
</LinkButton>
|
||
) : (
|
||
<Feedback
|
||
defaultValue="enterprise"
|
||
button={<Button variant="secondary/small">Request more</Button>}
|
||
/>
|
||
)
|
||
) : 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 showSelfServe = useShowSelfServe();
|
||
const fetcher = useFetcher();
|
||
const lastSubmission =
|
||
fetcher.data && typeof fetcher.data === "object" && "status" in fetcher.data
|
||
? fetcher.data
|
||
: undefined;
|
||
const [form, { amount }] = useForm({
|
||
id: "purchase-branches",
|
||
lastResult: lastSubmission as any,
|
||
onValidate({ formData }) {
|
||
return parseWithZod(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…";
|
||
|
||
if (!showSelfServe) {
|
||
return (
|
||
<Feedback
|
||
defaultValue="enterprise"
|
||
button={<Button variant="secondary/small">Request more</Button>}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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" {...getFormProps(form)}>
|
||
<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
|
||
{...getInputProps(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.errors}</FormError>
|
||
<FormError>{form.errors}</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";
|
||
}
|