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

242 lines
8.8 KiB
TypeScript

import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
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 { DateTime } from "~/components/primitives/DateTime";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { formatCurrency } from "~/utils/numberFormatter";
export const billingLimitRecoveryFormSchema = z
.object({
action: z.enum(["increase", "remove"]),
newAmount: z.coerce
.number({ invalid_type_error: "Not a valid amount" })
.positive("Amount must be greater than 0")
.optional(),
resumeMode: z.enum(["queue", "new_only"]),
})
.superRefine((value, ctx) => {
if (value.action === "increase" && (value.newAmount === undefined || value.newAmount <= 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Amount must be greater than 0",
path: ["newAmount"],
});
}
});
type BillingLimitRecoveryActionData = {
formIntent: "billing-limit-resolve";
submission: SubmissionResult;
};
export function BillingLimitRecoveryPanel({
billingLimit,
currentSpendCents,
queuedRunCount,
suggestedNewLimitDollars,
}: {
billingLimit: BillingLimitResult & { isConfigured: true };
currentSpendCents: number;
queuedRunCount: number;
suggestedNewLimitDollars: number;
}) {
const { limitState } = billingLimit;
const isGrace = limitState.status === "grace";
const isRejected = limitState.status === "rejected";
const [action, setAction] = useState<"increase" | "remove">("increase");
const [newAmount, setNewAmount] = useState(String(suggestedNewLimitDollars));
const [resumeMode, setResumeMode] = useState<"queue" | "new_only">("queue");
const newAmountInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
setNewAmount(String(suggestedNewLimitDollars));
}, [suggestedNewLimitDollars]);
function handleActionChange(value: string) {
const nextAction = value as typeof action;
setAction(nextAction);
if (nextAction === "increase") {
window.setTimeout(() => newAmountInputRef.current?.focus(), 0);
}
}
const actionData = useActionData<BillingLimitRecoveryActionData>();
const recoverySubmission =
actionData?.formIntent === "billing-limit-resolve" ? actionData.submission : undefined;
const [form, fields] = useForm({
id: "billing-limit-resolve",
lastResult: recoverySubmission as any,
shouldRevalidate: "onInput",
onValidate({ formData }) {
return parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
},
defaultValue: {
action: "increase",
newAmount: suggestedNewLimitDollars,
resumeMode: "queue",
},
});
useEffect(() => {
formRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
}, [action, newAmount, resumeMode]);
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const queuedRunsLabel = useMemo(() => {
if (queuedRunCount === 0) {
return null;
}
return `~${queuedRunCount.toLocaleString()} run${
queuedRunCount === 1 ? "" : "s"
} waiting in queue`;
}, [queuedRunCount]);
return (
<div className="space-y-6">
<div className="border-b border-grid-dimmed pb-3">
<Header2 spacing>Action required</Header2>
<Callout variant="warning">
<Paragraph variant="small" className="text-yellow-200">
{isGrace ? (
<>
Your organization has reached its billing limit. Processing is paused and new runs
are queuing. Without action, new triggers block on{" "}
<DateTime date={limitState.graceEndsAt} includeTime />.
</>
) : (
"Your organization has exceeded its billing limit. New triggers are blocked until you resolve this."
)}
</Paragraph>
</Callout>
</div>
<div className="space-y-2">
<Paragraph variant="small">
Current usage: {formatCurrency(currentSpendCents / 100, false)}
</Paragraph>
{billingLimit.effectiveAmountCents !== null && (
<Paragraph variant="small">
Current limit: {formatCurrency(billingLimit.effectiveAmountCents / 100, false)}
</Paragraph>
)}
{queuedRunsLabel && <Paragraph variant="small">{queuedRunsLabel}</Paragraph>}
{isRejected && queuedRunsLabel && (
<Paragraph variant="small">New triggers are currently blocked.</Paragraph>
)}
</div>
<Form method="post" {...getFormProps(form)} ref={formRef}>
<input type="hidden" name="intent" value="billing-limit-resolve" />
<input type="hidden" name="action" value={action} />
<input type="hidden" name="resumeMode" value={resumeMode} />
<Fieldset className="space-y-6">
<div className="space-y-3">
<Header2 spacing className="text-base">
How would you like to resolve this?
</Header2>
<RadioGroup
value={action}
onValueChange={handleActionChange}
className="flex flex-col gap-2"
>
<div className="space-y-2">
<RadioGroupItem
id="resolve_action_increase"
value="increase"
variant="description"
label="Increase billing limit"
description="Continue running with a higher monthly cap."
/>
{action === "increase" && (
<InputGroup fullWidth>
<Input
ref={newAmountInputRef}
id="new-amount"
name="newAmount"
type="number"
step={0.01}
min={0.01}
value={newAmount}
onChange={(e) => setNewAmount(e.target.value)}
placeholder="New limit amount"
icon={
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
$
</span>
}
/>
</InputGroup>
)}
{action === "increase" && fields.newAmount?.errors && (
<FormError id={fields.newAmount?.errorId}>{fields.newAmount.errors}</FormError>
)}
</div>
<RadioGroupItem
id="resolve_action_remove"
value="remove"
variant="description"
label="Remove billing limit"
description="Keep going without a ceiling on usage."
/>
</RadioGroup>
</div>
<div className="space-y-3 border-t border-grid-dimmed pt-6">
<Header2 spacing className="text-base">
What should happen to queued runs?
</Header2>
<RadioGroup
value={resumeMode}
onValueChange={(value) => setResumeMode(value as typeof resumeMode)}
className="flex flex-col gap-2"
>
<RadioGroupItem
id="resume_mode_queue"
value="queue"
variant="description"
label="Resume queued runs"
description="Everything built up during the pause will run in order."
/>
<RadioGroupItem
id="resume_mode_new_only"
value="new_only"
variant="description"
label="Cancel queued runs"
description="Nothing from the pause is kept — only new triggers going forward."
/>
</RadioGroup>
</div>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
{isSubmitting ? "Resolving…" : "Resolve"}
</Button>
}
/>
</Fieldset>
</Form>
</div>
);
}