refactor(frontend): extract AddCreditsModal into separate component file (#13490)

This commit is contained in:
Hiep Le
2026-03-20 00:12:48 +07:00
committed by GitHub
parent 04330898b6
commit b44774d2be
4 changed files with 455 additions and 399 deletions

View File

@@ -0,0 +1,351 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
import BillingService from "#/api/billing-service/billing-service.api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: vi.fn(),
},
}),
}));
describe("AddCreditsModal", () => {
const onCloseMock = vi.fn();
const renderModal = () => {
const user = userEvent.setup();
renderWithProviders(<AddCreditsModal onClose={onCloseMock} />);
return { user };
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rendering", () => {
it("should render the form with correct elements", () => {
renderModal();
expect(screen.getByTestId("add-credits-form")).toBeInTheDocument();
expect(screen.getByTestId("amount-input")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ORG\$NEXT/i })).toBeInTheDocument();
});
it("should display the title", () => {
renderModal();
expect(screen.getByText("ORG$ADD_CREDITS")).toBeInTheDocument();
});
});
describe("Button State Management", () => {
it("should enable submit button initially when modal opens", () => {
renderModal();
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button when input contains invalid value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "-50");
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button when input contains valid value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "100");
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button after validation error is shown", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
});
expect(nextButton).not.toBeDisabled();
});
});
describe("Input Attributes & Placeholder", () => {
it("should have min attribute set to 10", () => {
renderModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("min", "10");
});
it("should have max attribute set to 25000", () => {
renderModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("max", "25000");
});
it("should have step attribute set to 1", () => {
renderModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("step", "1");
});
});
describe("Error Message Display", () => {
it("should not display error message initially when modal opens", () => {
renderModal();
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
it("should display error message after submitting amount above maximum", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "25001");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
});
});
it("should display error message after submitting decimal value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "50.5");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER");
});
});
it("should display error message after submitting amount below minimum", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
});
it("should display error message after submitting negative amount", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "-50");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
});
});
it("should replace error message when submitting different invalid value", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
await user.clear(amountInput);
await user.type(amountInput, "25001");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
});
});
});
describe("Form Submission Behavior", () => {
it("should prevent submission when amount is invalid", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "9");
await user.click(nextButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
});
it("should call createCheckoutSession with correct amount when valid", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "1000");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
it("should not call createCheckoutSession when validation fails", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "-50");
await user.click(nextButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
});
});
it("should close modal on successful submission", async () => {
vi.spyOn(BillingService, "createCheckoutSession").mockResolvedValue(
"https://checkout.stripe.com/test-session",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "1000");
await user.click(nextButton);
await waitFor(() => {
expect(onCloseMock).toHaveBeenCalled();
});
});
it("should allow API call when validation passes and clear any previous errors", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
// First submit invalid value
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
});
// Then submit valid value
await user.clear(amountInput);
await user.type(amountInput, "100");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
});
describe("Edge Cases", () => {
it("should handle zero value correctly", async () => {
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
await user.type(amountInput, "0");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
});
});
it("should handle whitespace-only input correctly", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = renderModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
// Number inputs typically don't accept spaces, but test the behavior
await user.type(amountInput, " ");
await user.click(nextButton);
// Should not call API (empty/invalid input)
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
});
describe("Modal Interaction", () => {
it("should call onClose when cancel button is clicked", async () => {
const { user } = renderModal();
const cancelButton = screen.getByRole("button", { name: /close/i });
await user.click(cancelButton);
expect(onCloseMock).toHaveBeenCalledOnce();
});
});
});

View File

@@ -283,305 +283,6 @@ describe("Manage Org Route", () => {
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
describe("AddCreditsModal", () => {
const openAddCreditsModal = async () => {
const user = userEvent.setup();
renderManageOrg();
await screen.findByTestId("manage-org-screen");
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
await user.click(addCreditsButton);
const addCreditsForm = screen.getByTestId("add-credits-form");
expect(addCreditsForm).toBeInTheDocument();
return { user, addCreditsForm };
};
describe("Button State Management", () => {
it("should enable submit button initially when modal opens", async () => {
await openAddCreditsModal();
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button when input contains invalid value", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "-50");
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button when input contains valid value", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "100");
expect(nextButton).not.toBeDisabled();
});
it("should enable submit button after validation error is shown", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
});
expect(nextButton).not.toBeDisabled();
});
});
describe("Input Attributes & Placeholder", () => {
it("should have min attribute set to 10", async () => {
await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("min", "10");
});
it("should have max attribute set to 25000", async () => {
await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("max", "25000");
});
it("should have step attribute set to 1", async () => {
await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute("step", "1");
});
});
describe("Error Message Display", () => {
it("should not display error message initially when modal opens", async () => {
await openAddCreditsModal();
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
it("should display error message after submitting amount above maximum", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "25001");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
);
});
});
it("should display error message after submitting decimal value", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "50.5");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER",
);
});
});
it("should replace error message when submitting different invalid value", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_MINIMUM_AMOUNT",
);
});
await user.clear(amountInput);
await user.type(amountInput, "25001");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
);
});
});
});
describe("Form Submission Behavior", () => {
it("should prevent submission when amount is invalid", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "9");
await user.click(nextButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_MINIMUM_AMOUNT",
);
});
});
it("should call createCheckoutSession with correct amount when valid", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "1000");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
it("should not call createCheckoutSession when validation fails", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "-50");
await user.click(nextButton);
// Verify mutation was not called
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_NEGATIVE_AMOUNT",
);
});
});
it("should close modal on successful submission", async () => {
const createCheckoutSessionSpy = vi
.spyOn(BillingService, "createCheckoutSession")
.mockResolvedValue("https://checkout.stripe.com/test-session");
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "1000");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
await waitFor(() => {
expect(
screen.queryByTestId("add-credits-form"),
).not.toBeInTheDocument();
});
});
it("should allow API call when validation passes and clear any previous errors", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
// First submit invalid value
await user.type(amountInput, "9");
await user.click(nextButton);
await waitFor(() => {
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
});
// Then submit valid value
await user.clear(amountInput);
await user.type(amountInput, "100");
await user.click(nextButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
const errorMessage = screen.queryByTestId("amount-error");
expect(errorMessage).not.toBeInTheDocument();
});
});
describe("Edge Cases", () => {
it("should handle zero value correctly", async () => {
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
await user.type(amountInput, "0");
await user.click(nextButton);
await waitFor(() => {
const errorMessage = screen.getByTestId("amount-error");
expect(errorMessage).toHaveTextContent(
"PAYMENT$ERROR_MINIMUM_AMOUNT",
);
});
});
it("should handle whitespace-only input correctly", async () => {
const createCheckoutSessionSpy = vi.spyOn(
BillingService,
"createCheckoutSession",
);
const { user } = await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
const nextButton = screen.getByRole("button", { name: /next/i });
// Number inputs typically don't accept spaces, but test the behavior
await user.type(amountInput, " ");
await user.click(nextButton);
// Should not call API (empty/invalid input)
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
});
});
it("should show add credits option for ADMIN role", async () => {
renderManageOrg();
await screen.findByTestId("manage-org-screen");

View File

@@ -0,0 +1,103 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { I18nKey } from "#/i18n/declaration";
import { amountIsValid } from "#/utils/amount-is-valid";
interface AddCreditsModalProps {
onClose: () => void;
}
export function AddCreditsModal({ onClose }: AddCreditsModalProps) {
const { t } = useTranslation();
const { mutate: addBalance } = useCreateStripeCheckoutSession();
const [inputValue, setInputValue] = React.useState("");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const getErrorMessage = (value: string): string | null => {
if (!value.trim()) return null;
const numValue = parseInt(value, 10);
if (Number.isNaN(numValue)) {
return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER);
}
if (numValue < 0) {
return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT);
}
if (numValue < 10) {
return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT);
}
if (numValue > 25000) {
return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT);
}
if (numValue !== parseFloat(value)) {
return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER);
}
return null;
};
const formAction = (formData: FormData) => {
const amount = formData.get("amount")?.toString();
if (amount?.trim()) {
if (!amountIsValid(amount)) {
const error = getErrorMessage(amount);
setErrorMessage(error || "Invalid amount");
return;
}
const intValue = parseInt(amount, 10);
addBalance({ amount: intValue }, { onSuccess: onClose });
setErrorMessage(null);
}
};
const handleAmountInputChange = (value: string) => {
setInputValue(value);
setErrorMessage(null);
};
return (
<ModalBackdrop onClose={onClose}>
<form
data-testid="add-credits-form"
action={formAction}
noValidate
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
>
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
<div className="flex flex-col gap-2">
<SettingsInput
testId="amount-input"
name="amount"
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
type="number"
min={10}
max={25000}
step={1}
value={inputValue}
onChange={(value) => handleAmountInputChange(value)}
className="w-full"
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
{errorMessage}
</p>
)}
</div>
<ModalButtonGroup
primaryText={t(I18nKey.ORG$NEXT)}
onSecondaryClick={onClose}
primaryType="submit"
/>
</form>
</ModalBackdrop>
);
}

View File

@@ -1,14 +1,9 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { useOrganization } from "#/hooks/query/use-organization";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { useMe } from "#/hooks/query/use-me";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import { amountIsValid } from "#/utils/amount-is-valid";
import { CreditsChip } from "#/ui/credits-chip";
import { InteractiveChip } from "#/ui/interactive-chip";
import { usePermission } from "#/hooks/organizations/use-permissions";
@@ -16,104 +11,10 @@ import { createPermissionGuard } from "#/utils/org/permission-guard";
import { isBillingHidden } from "#/utils/org/billing-visibility";
import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal";
import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal";
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
import { useBalance } from "#/hooks/query/use-balance";
import { cn } from "#/utils/utils";
interface AddCreditsModalProps {
onClose: () => void;
}
function AddCreditsModal({ onClose }: AddCreditsModalProps) {
const { t } = useTranslation();
const { mutate: addBalance } = useCreateStripeCheckoutSession();
const [inputValue, setInputValue] = React.useState("");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const getErrorMessage = (value: string): string | null => {
if (!value.trim()) return null;
const numValue = parseInt(value, 10);
if (Number.isNaN(numValue)) {
return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER);
}
if (numValue < 0) {
return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT);
}
if (numValue < 10) {
return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT);
}
if (numValue > 25000) {
return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT);
}
if (numValue !== parseFloat(value)) {
return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER);
}
return null;
};
const formAction = (formData: FormData) => {
const amount = formData.get("amount")?.toString();
if (amount?.trim()) {
if (!amountIsValid(amount)) {
const error = getErrorMessage(amount);
setErrorMessage(error || "Invalid amount");
return;
}
const intValue = parseInt(amount, 10);
addBalance({ amount: intValue }, { onSuccess: onClose });
setErrorMessage(null);
}
};
const handleAmountInputChange = (value: string) => {
setInputValue(value);
setErrorMessage(null);
};
return (
<ModalBackdrop onClose={onClose}>
<form
data-testid="add-credits-form"
action={formAction}
noValidate
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
>
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
<div className="flex flex-col gap-2">
<SettingsInput
testId="amount-input"
name="amount"
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
type="number"
min={10}
max={25000}
step={1}
value={inputValue}
onChange={(value) => handleAmountInputChange(value)}
className="w-full"
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
{errorMessage}
</p>
)}
</div>
<ModalButtonGroup
primaryText={t(I18nKey.ORG$NEXT)}
onSecondaryClick={onClose}
primaryType="submit"
/>
</form>
</ModalBackdrop>
);
}
export const clientLoader = createPermissionGuard("view_billing");
function ManageOrg() {