diff --git a/frontend/__tests__/components/features/org/add-credits-modal.test.tsx b/frontend/__tests__/components/features/org/add-credits-modal.test.tsx new file mode 100644 index 0000000000..1c049aedcd --- /dev/null +++ b/frontend/__tests__/components/features/org/add-credits-modal.test.tsx @@ -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()), + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), +})); + +describe("AddCreditsModal", () => { + const onCloseMock = vi.fn(); + + const renderModal = () => { + const user = userEvent.setup(); + renderWithProviders(); + 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(); + }); + }); +}); diff --git a/frontend/__tests__/routes/manage-org.test.tsx b/frontend/__tests__/routes/manage-org.test.tsx index 390b10fc43..8f5cc137be 100644 --- a/frontend/__tests__/routes/manage-org.test.tsx +++ b/frontend/__tests__/routes/manage-org.test.tsx @@ -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"); diff --git a/frontend/src/components/features/org/add-credits-modal.tsx b/frontend/src/components/features/org/add-credits-modal.tsx new file mode 100644 index 0000000000..78ef6519b2 --- /dev/null +++ b/frontend/src/components/features/org/add-credits-modal.tsx @@ -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(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 ( + +
+

{t(I18nKey.ORG$ADD_CREDITS)}

+
+ handleAmountInputChange(value)} + className="w-full" + /> + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + + +
+ ); +} diff --git a/frontend/src/routes/manage-org.tsx b/frontend/src/routes/manage-org.tsx index cff5429344..cc14274923 100644 --- a/frontend/src/routes/manage-org.tsx +++ b/frontend/src/routes/manage-org.tsx @@ -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(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 ( - -
-

{t(I18nKey.ORG$ADD_CREDITS)}

-
- handleAmountInputChange(value)} - className="w-full" - /> - {errorMessage && ( -

- {errorMessage} -

- )} -
- - - -
- ); -} - export const clientLoader = createPermissionGuard("view_billing"); function ManageOrg() {