chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user