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(); 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 ( Preview branches} /> ); } return ( Preview branches} /> {branches.map((branch) => ( {branch.branchName} {branch.id} ))} Branches docs {limits.isAtLimit ? ( ) : ( New branch… } env="preview" /> )}
{!hasBranches ? ( ) : ( <>
Branch Created Git Archived Actions {branches.length === 0 ? ( There are no matches for your filters ) : ( branches.map((branch) => { const path = branchesPath(organization, project, branch); const cellClass = branch.archivedAt ? "opacity-50" : ""; const isSelected = branch.id === environment.id; return (
{isSelected && Current}
{branch.archivedAt ? ( ) : ( "–" )} Switch to branch ) } popoverContent={ !isSelected || !branch.archivedAt ? ( <> {isSelected ? null : ( )} {!branch.archivedAt ? ( ) : null} ) : null } />
); }) )}
} content={`${Math.round(usageRatio * 100)}%`} />
{requiresUpgrade ? ( You've used all {limits.limit} of your branches. Archive one or upgrade your plan to enable more. ) : (
You've used {limits.used}/{limits.limit} of your branches
)} {canPurchaseBranches && branchPricing ? ( ) : canUpgrade ? ( showSelfServe ? (
Upgrade plan for more Preview Branches Upgrade
) : ( Request more} /> ) ) : null}
)}
); } 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 (
); } 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 ( Purchase more… } /> ); } return ( You've exceeded your limit
You've used {limits.used}/{limits.limit} of your branches. You can archive one or upgrade your plan for more.
{canUpgrade ? ( showSelfServe ? ( Upgrade ) : ( Request more} /> ) ) : null}
); } 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 ( Request more} /> ); } return ( {triggerButton ?? ( )} {title}
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).
setAmountValue(Number(e.target.value))} disabled={isLoading} /> {amount.errors} {form.errors}
{state === "need_to_archive" ? (
You need to archive{" "} {formatNumber(activeBranches - (planBranchLimit + amountValue))} more{" "} {activeBranches - (planBranchLimit + amountValue) === 1 ? "branch" : "branches"}{" "} before you can reduce to this level.
) : state === "above_quota" ? (
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.
) : (
Summary Total
{formatNumber(extraBranches)} current extra {formatCurrency(extraBranches * pricePerBranch, true)}
({extraBranches} {extraBranches === 1 ? "branch" : "branches"}) /mth
{state === "increase" ? "+" : null} {formatNumber(amountValue - extraBranches)} {state === "increase" ? "+" : null} {formatCurrency((amountValue - extraBranches) * pricePerBranch, true)}
({Math.abs(amountValue - extraBranches)}{" "} {Math.abs(amountValue - extraBranches) === 1 ? "branch" : "branches"} @{" "} {formatCurrency(pricePerBranch, true)}/mth) /mth
{formatNumber(amountValue)} new total {formatCurrency(amountValue * pricePerBranch, true)}
({amountValue} {amountValue === 1 ? "branch" : "branches"}) /mth
)}
) : state === "decrease" || state === "need_to_archive" ? ( <> ) : ( <> ) } cancelButton={ } />
); } 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"; }