Files
2026-07-13 13:32:57 +08:00

334 lines
12 KiB
TypeScript

import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { formatCurrency } from "~/utils/numberFormatter";
export const billingLimitFormSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("none"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
.optional(),
}),
z.object({
mode: z.literal("plan"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
.optional(),
}),
z.object({
mode: z.literal("custom"),
amount: z.coerce
.number({ invalid_type_error: "Not a valid amount" })
.positive("Amount must be greater than 0"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
.optional(),
}),
]);
type BillingLimitActionData = {
formIntent: "billing-limit";
submission: SubmissionResult;
};
export function isBillingLimitFormDirty(input: {
billingLimit: BillingLimitResult;
mode: "none" | "plan" | "custom";
customAmount: string;
cancelInProgressRuns: boolean;
}): boolean {
const needsInitialSave = !input.billingLimit.isConfigured;
const savedMode = getBillingLimitMode(input.billingLimit);
const savedCustomAmount =
input.billingLimit.isConfigured && input.billingLimit.mode === "custom"
? (input.billingLimit.amountCents / 100).toFixed(2)
: "";
const savedCancelInProgressRuns =
input.billingLimit.isConfigured && input.billingLimit.cancelInProgressRuns;
const isLimitDirty =
input.mode !== savedMode ||
(input.mode === "custom" && input.customAmount !== savedCustomAmount);
return (
needsInitialSave || isLimitDirty || input.cancelInProgressRuns !== savedCancelInProgressRuns
);
}
export function getBillingLimitFormLastSubmission(
submission: BillingLimitActionData["submission"] | undefined,
mode: "none" | "plan" | "custom",
isDirty: boolean
) {
if (!isDirty || !submission) {
return undefined;
}
if (mode !== "custom" && submission.error?.amount) {
const { amount: _amount, ...remainingErrors } = submission.error;
return {
...submission,
error: remainingErrors,
};
}
return submission;
}
type BillingLimitConfigSectionProps = {
billingLimit: BillingLimitResult;
planLimitCents: number;
};
export function BillingLimitConfigSection({
billingLimit,
planLimitCents,
}: BillingLimitConfigSectionProps) {
const gracePeriodLabel = formatGracePeriodMs(billingLimit.gracePeriodMs);
const savedMode = getBillingLimitMode(billingLimit);
const savedCustomAmount =
billingLimit.isConfigured && billingLimit.mode === "custom"
? (billingLimit.amountCents / 100).toFixed(2)
: "";
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
const customAmountInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
setMode(savedMode);
setCustomAmount(savedCustomAmount);
setCancelInProgressRuns(savedCancelInProgressRuns);
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
function handleModeChange(value: string) {
const nextMode = value as typeof mode;
if (mode === "custom" && nextMode !== "custom") {
setCustomAmount(savedCustomAmount);
}
setMode(nextMode);
if (nextMode === "custom") {
window.setTimeout(() => customAmountInputRef.current?.focus(), 0);
}
}
const actionData = useActionData<BillingLimitActionData>();
const limitSubmission =
actionData?.formIntent === "billing-limit" ? actionData.submission : undefined;
const _needsInitialSave = !billingLimit.isConfigured;
const _isLimitDirty =
mode !== savedMode || (mode === "custom" && customAmount !== savedCustomAmount);
const isDirty = isBillingLimitFormDirty({
billingLimit,
mode,
customAmount,
cancelInProgressRuns,
});
const lastSubmission = useMemo(
() => getBillingLimitFormLastSubmission(limitSubmission, mode, isDirty),
[limitSubmission, mode, isDirty]
);
const [form, fields] = useForm({
id: "billing-limit",
lastResult: lastSubmission as any,
shouldRevalidate: "onInput",
onValidate({ formData }) {
return parseWithZod(formData, { schema: billingLimitFormSchema });
},
defaultValue: {
mode: savedMode,
},
});
useEffect(() => {
formRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
}, [customAmount, mode]);
const planLimitLabel = formatCurrency(planLimitCents / 100, false);
const showPlanInfoCallout = mode === "plan";
const showCustomInfoCallout = mode === "custom";
const showNoneWarningCallout = mode === "none";
return (
<div>
<div className="mb-3 border-b border-grid-dimmed pb-3">
<Header2 spacing>Billing limit</Header2>
<Paragraph variant="small">
Set a monthly compute spend limit for your organization. When the limit is reached,
billable environments enter a grace period before new triggers are rejected.
</Paragraph>
</div>
<Form method="post" {...getFormProps(form)} ref={formRef}>
<input type="hidden" name="intent" value="billing-limit" />
<Fieldset>
<input type="hidden" name="mode" value={mode} />
<RadioGroup value={mode} onValueChange={handleModeChange} className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
<RadioGroupItem
id="limit_mode_plan"
value="plan"
variant="description"
label={
<span className="inline-flex items-center">{`My plan limit (${planLimitLabel})`}</span>
}
description={`Pause billable environments when monthly spend reaches ${planLimitLabel}.`}
/>
<div className="relative">
<AnimatedCallout
show={showPlanInfoCallout}
variant="info"
className="absolute w-full text-sm"
>
<LimitReachedCalloutContent
gracePeriodLabel={gracePeriodLabel}
cancelInProgressRuns={cancelInProgressRuns}
/>
</AnimatedCallout>
</div>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
<div className="flex flex-col gap-2">
<RadioGroupItem
id="limit_mode_custom"
value="custom"
variant="description"
label="Custom limit"
description="Set your own monthly spend threshold."
/>
{mode === "custom" && (
<InputGroup fullWidth className="mb-1 mt-0.5">
<Input
ref={customAmountInputRef}
id="custom-amount"
name="amount"
type="number"
step={0.01}
min={0.01}
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="Custom limit amount"
icon={
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
$
</span>
}
className="pl-px"
fullWidth
/>
{fields.amount && (
<FormError id={fields.amount.errorId}>{fields.amount.errors}</FormError>
)}
</InputGroup>
)}
</div>
<div className="relative">
<AnimatedCallout
show={showCustomInfoCallout}
variant="info"
className="absolute w-full text-sm"
>
<LimitReachedCalloutContent
gracePeriodLabel={gracePeriodLabel}
cancelInProgressRuns={cancelInProgressRuns}
/>
</AnimatedCallout>
</div>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
<RadioGroupItem
id="limit_mode_none"
value="none"
variant="description"
label="I don't want a billing limit"
description="Runs continue without protection against runaway usage."
/>
<div className="relative">
<AnimatedCallout
show={showNoneWarningCallout}
variant="warning"
className="absolute w-full"
>
Without a billing limit, runs will continue even if usage spikes unexpectedly. You
may have to pay higher fees before you notice.
</AnimatedCallout>
</div>
</div>
</RadioGroup>
{mode !== "none" && (
<CheckboxWithLabel
className="mt-4"
name="cancelInProgressRuns"
id="cancel_in_progress_runs"
value="on"
variant="simple/small"
label="Cancel in-progress runs when this limit is reached"
defaultChecked={cancelInProgressRuns}
onChange={setCancelInProgressRuns}
/>
)}
<FormButtons
className={isDirty ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!isDirty}>
Save billing limit
</Button>
}
/>
</Fieldset>
</Form>
</div>
);
}
function LimitReachedCalloutContent({
gracePeriodLabel,
cancelInProgressRuns,
}: {
gracePeriodLabel: string;
cancelInProgressRuns: boolean;
}) {
return (
<>
When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers
will be rejected until you increase or remove the limit. Limits are enforced with a short
delay, so spend may briefly exceed the limit before grace begins. See our{" "}
<a href="https://trigger.dev/terms" className="underline">
terms
</a>{" "}
for refund policy details.
{cancelInProgressRuns ? (
<> In-progress runs will be cancelled when the limit is hit.</>
) : null}
</>
);
}