diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index 6a65befc38f6..72d5300f3f03 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -6,6 +6,8 @@ - Linux - Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements) +A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands. + ## Prerequisites
diff --git a/frontend/__tests__/components/features/payment/payment-form.test.tsx b/frontend/__tests__/components/features/payment/payment-form.test.tsx new file mode 100644 index 000000000000..815d0530d103 --- /dev/null +++ b/frontend/__tests__/components/features/payment/payment-form.test.tsx @@ -0,0 +1,166 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; +import OpenHands from "#/api/open-hands"; +import { PaymentForm } from "#/components/features/payment/payment-form"; + +describe("PaymentForm", () => { + const getBalanceSpy = vi.spyOn(OpenHands, "getBalance"); + const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession"); + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + + const renderPaymentForm = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + beforeEach(() => { + // useBalance hook will return the balance only if the APP_MODE is "saas" + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the users current balance", async () => { + getBalanceSpy.mockResolvedValue("100.50"); + renderPaymentForm(); + + await waitFor(() => { + const balance = screen.getByTestId("user-balance"); + expect(balance).toHaveTextContent("$100.50"); + }); + }); + + it("should render the users current balance to two decimal places", async () => { + getBalanceSpy.mockResolvedValue("100"); + renderPaymentForm(); + + await waitFor(() => { + const balance = screen.getByTestId("user-balance"); + expect(balance).toHaveTextContent("$100.00"); + }); + }); + + test("the user can top-up a specific amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "50.12"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12); + }); + + it("should round the top-up amount to two decimal places", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "50.125456"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13); + }); + + it("should render the payment method link", async () => { + renderPaymentForm(); + + screen.getByTestId("payment-methods-link"); + }); + + it("should disable the top-up button if the user enters an invalid amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpButton = screen.getByText("Add credit"); + expect(topUpButton).toBeDisabled(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, " "); + + expect(topUpButton).toBeDisabled(); + }); + + it("should disable the top-up button after submission", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "50.12"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(topUpButton).toBeDisabled(); + }); + + describe("prevent submission if", () => { + test("user enters a negative amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "-50.12"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + test("user enters an empty string", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, " "); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + test("user enters a non-numeric value", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "abc"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + test("user enters less than the minimum amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "20"); // test assumes the minimum is 25 + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/components/settings/settings-input.test.tsx b/frontend/__tests__/components/settings/settings-input.test.tsx index 6009a2409e83..c2c6f650679d 100644 --- a/frontend/__tests__/components/settings/settings-input.test.tsx +++ b/frontend/__tests__/components/settings/settings-input.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import { SettingsInput } from "#/components/features/settings/settings-input"; describe("SettingsInput", () => { @@ -85,4 +86,24 @@ describe("SettingsInput", () => { expect(screen.getByText("Start Content")).toBeInTheDocument(); }); + + it("should call onChange with the input value", async () => { + const onChangeMock = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const input = screen.getByTestId("test-input"); + await user.type(input, "Test"); + + expect(onChangeMock).toHaveBeenCalledTimes(4); + expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test"); + }); }); diff --git a/frontend/__tests__/routes/home.test.tsx b/frontend/__tests__/routes/home.test.tsx index ec7a24761f60..64232673355e 100644 --- a/frontend/__tests__/routes/home.test.tsx +++ b/frontend/__tests__/routes/home.test.tsx @@ -39,12 +39,12 @@ describe("Home Screen", () => { Component: Home, path: "/", }, + { + Component: SettingsScreen, + path: "/settings", + }, ], }, - { - Component: SettingsScreen, - path: "/settings", - }, ]); afterEach(() => { @@ -96,6 +96,9 @@ describe("Home Screen", () => { const user = userEvent.setup(); renderWithProviders(); + const settingsScreen = screen.queryByTestId("settings-screen"); + expect(settingsScreen).not.toBeInTheDocument(); + const settingsModal = await screen.findByTestId("ai-config-modal"); expect(settingsModal).toBeInTheDocument(); @@ -104,11 +107,11 @@ describe("Home Screen", () => { ); await user.click(advancedSettingsButton); + const settingsScreenAfter = await screen.findByTestId("settings-screen"); + expect(settingsScreenAfter).toBeInTheDocument(); + const settingsModalAfter = screen.queryByTestId("ai-config-modal"); expect(settingsModalAfter).not.toBeInTheDocument(); - - const settingsScreen = await screen.findByTestId("settings-screen"); - expect(settingsScreen).toBeInTheDocument(); }); }); }); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx new file mode 100644 index 000000000000..69f9b8c364b1 --- /dev/null +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -0,0 +1,83 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoutesStub } from "react-router"; +import { renderWithProviders } from "test-utils"; +import OpenHands from "#/api/open-hands"; +import SettingsScreen from "#/routes/settings"; +import { PaymentForm } from "#/components/features/payment/payment-form"; +import * as FeatureFlags from "#/utils/feature-flags"; + +describe("Settings Billing", () => { + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true); + + const RoutesStub = createRoutesStub([ + { + Component: SettingsScreen, + path: "/settings", + children: [ + { + Component: () => , + path: "/settings/billing", + }, + ], + }, + ]); + + const renderSettingsScreen = () => + renderWithProviders(); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should not render the navbar if OSS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const navbar = screen.queryByTestId("settings-navbar"); + expect(navbar).not.toBeInTheDocument(); + }); + }); + + it("should render the navbar if SaaS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const navbar = screen.getByTestId("settings-navbar"); + within(navbar).getByText("Account"); + within(navbar).getByText("Credits"); + }); + }); + + it("should render the billing settings if clicking the credits item", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + const credits = within(navbar).getByText("Credits"); + await user.click(credits); + + const billingSection = await screen.findByTestId("billing-settings"); + within(billingSection).getByText("Manage Credits"); + }); +}); diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index 2052d6eb9cbe..f307e4fb4b17 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -10,6 +10,7 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { PostApiSettings } from "#/types/settings"; import * as ConsentHandlers from "#/utils/handle-capture-consent"; +import AccountSettings from "#/routes/account-settings"; const toggleAdvancedSettings = async (user: UserEvent) => { const advancedSwitch = await screen.findByTestId("advanced-settings-switch"); @@ -36,6 +37,7 @@ describe("Settings Screen", () => { { Component: SettingsScreen, path: "/settings", + children: [{ Component: AccountSettings, path: "/settings" }], }, ]); @@ -76,7 +78,8 @@ describe("Settings Screen", () => { }); }); - it("should render an indicator if the GitHub token is not set", async () => { + // TODO: Set a better unset indicator + it.skip("should render an indicator if the GitHub token is not set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, github_token_is_set: false, @@ -97,6 +100,20 @@ describe("Settings Screen", () => { }); }); + it("should set asterik placeholder if the GitHub token is set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("github-token-input"); + expect(input).toHaveProperty("placeholder", "**********"); + }); + }); + it("should render an indicator if the GitHub token is set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -314,7 +331,8 @@ describe("Settings Screen", () => { // screen.getByTestId("security-analyzer-input"); }); - it("should render an indicator if the LLM API key is not set", async () => { + // TODO: Set a better unset indicator + it.skip("should render an indicator if the LLM API key is not set", async () => { getSettingsSpy.mockResolvedValueOnce({ ...MOCK_DEFAULT_USER_SETTINGS, llm_api_key: null, @@ -443,7 +461,22 @@ describe("Settings Screen", () => { expect(input).toHaveValue("1x (2 core, 8G)"); }); - it("should save the runtime settings when the 'Save Changes' button is clicked", async () => { + it("should always have the runtime input disabled", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(userEvent.setup()); + + const input = await screen.findByTestId("runtime-settings-input"); + expect(input).toBeDisabled(); + }); + + it.skip("should save the runtime settings when the 'Save Changes' button is clicked", async () => { const user = userEvent.setup(); getConfigSpy.mockResolvedValue({ APP_MODE: "saas", @@ -665,7 +698,7 @@ describe("Settings Screen", () => { expect(saveSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ - llm_api_key: undefined, + llm_api_key: "", // empty because it's not set previously github_token: undefined, language: "no", }), @@ -704,7 +737,7 @@ describe("Settings Screen", () => { expect(saveSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ github_token: undefined, - llm_api_key: undefined, + llm_api_key: "", // empty because it's not set previously llm_model: "openai/gpt-4o", }), ); @@ -869,5 +902,55 @@ describe("Settings Screen", () => { }), ); }); + + it("should send an empty LLM API Key if the user submits an empty string", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + const input = await screen.findByTestId("llm-api-key-input"); + expect(input).toHaveValue(""); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ llm_api_key: "" }), + ); + }); + + it("should not send an empty LLM API Key if the user submits an empty string but already has it set", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: "**********", + }); + + renderSettingsScreen(); + + const input = await screen.findByTestId("llm-api-key-input"); + expect(input).toHaveValue(""); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ llm_api_key: undefined }), + ); + }); + + it("should submit the LLM API Key if it is the first time the user sets it", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + const input = await screen.findByTestId("llm-api-key-input"); + await user.type(input, "new-api-key"); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ llm_api_key: "new-api-key" }), + ); + }); }); }); diff --git a/frontend/__tests__/utils/amount-is-valid.test.ts b/frontend/__tests__/utils/amount-is-valid.test.ts new file mode 100644 index 000000000000..30181e1b48c9 --- /dev/null +++ b/frontend/__tests__/utils/amount-is-valid.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { amountIsValid } from "#/utils/amount-is-valid"; + +describe("amountIsValid", () => { + describe("fails", () => { + test("when the amount is negative", () => { + expect(amountIsValid("-5")).toBe(false); + expect(amountIsValid("-25")).toBe(false); + }); + + test("when the amount is zero", () => { + expect(amountIsValid("0")).toBe(false); + }); + + test("when an empty string is passed", () => { + expect(amountIsValid("")).toBe(false); + expect(amountIsValid(" ")).toBe(false); + }); + + test("when a non-numeric value is passed", () => { + expect(amountIsValid("abc")).toBe(false); + expect(amountIsValid("1abc")).toBe(false); + expect(amountIsValid("abc1")).toBe(false); + }); + + test("when an amount less than the minimum is passed", () => { + // test assumes the minimum is 25 + expect(amountIsValid("24")).toBe(false); + expect(amountIsValid("24.99")).toBe(false); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a261adf741e7..f03e27c271f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,26 +14,28 @@ "@react-router/serve": "^7.1.5", "@react-types/shared": "^3.27.0", "@reduxjs/toolkit": "^2.5.1", - "@tanstack/react-query": "^5.66.0", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.5.0", + "@tanstack/react-query": "^5.66.7", "@vitejs/plugin-react": "^4.3.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.7.9", "clsx": "^2.1.1", "eslint-config-airbnb-typescript": "^18.0.0", - "framer-motion": "^12.4.2", + "framer-motion": "^12.4.4", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.3", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.22", - "jose": "^5.9.4", + "jose": "^5.10.0", "monaco-editor": "^0.52.2", - "posthog-js": "^1.217.6", + "posthog-js": "^1.219.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-highlight": "^0.15.0", "react-hot-toast": "^2.5.1", - "react-i18next": "^15.4.0", + "react-i18next": "^15.4.1", "react-icons": "^5.4.0", "react-markdown": "^9.0.3", "react-redux": "^9.2.0", @@ -41,7 +43,7 @@ "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.7", "remark-gfm": "^4.0.1", - "sirv-cli": "^3.0.0", + "sirv-cli": "^3.0.1", "socket.io-client": "^4.8.1", "tailwind-merge": "^3.0.1", "vite": "^6.1.0", @@ -66,7 +68,7 @@ "@types/ws": "^8.5.14", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", - "@vitest/coverage-v8": "^3.0.5", + "@vitest/coverage-v8": "^3.0.6", "autoprefixer": "^10.4.20", "cross-env": "^7.0.3", "eslint": "^8.57.0", @@ -84,6 +86,7 @@ "msw": "^2.6.6", "postcss": "^8.5.2", "prettier": "^3.5.1", + "stripe": "^17.5.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", @@ -5871,6 +5874,29 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz", + "integrity": "sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz", + "integrity": "sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -6139,9 +6165,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.66.3", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.3.tgz", - "integrity": "sha512-+2iDxH7UFdtwcry766aJszGmbByQDIzTltJ3oQAZF9bhCxHCIN3yDwHa6qDCZxcpMGvUphCRx/RYJvLbM8mucQ==", + "version": "5.66.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz", + "integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==", "license": "MIT", "funding": { "type": "github", @@ -6149,12 +6175,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.66.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.3.tgz", - "integrity": "sha512-sWMvxZ5VugPDgD1CzP7f0s9yFvjcXP3FXO5IVV2ndXlYqUCwykU8U69Kk05Qn5UvGRqB/gtj4J7vcTC6vtLHtQ==", + "version": "5.66.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.7.tgz", + "integrity": "sha512-qd3q/tUpF2K1xItfPZddk1k/8pSXnovg41XyCqJgPoyYEirMBtB0sVEVVQ/CsAOngzgWtBPXimVf4q4kM9uO6A==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.66.3" + "@tanstack/query-core": "5.66.4" }, "funding": { "type": "github", @@ -6703,16 +6729,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.24.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.0.tgz", - "integrity": "sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", + "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.24.0", - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/typescript-estree": "8.24.0" + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6727,14 +6753,14 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.24.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz", - "integrity": "sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", + "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/visitor-keys": "8.24.0" + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6745,9 +6771,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.24.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.0.tgz", - "integrity": "sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", + "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", "dev": true, "license": "MIT", "engines": { @@ -6759,14 +6785,14 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.24.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz", - "integrity": "sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", + "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/visitor-keys": "8.24.0", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -6786,13 +6812,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.24.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz", - "integrity": "sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", + "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/types": "8.24.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -6873,9 +6899,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", - "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz", + "integrity": "sha512-JRTlR8Bw+4BcmVTICa7tJsxqphAktakiLsAmibVLAWbu1lauFddY/tXeM6sAyl1cgkPuXtpnUgaCPhTdz1Qapg==", "dev": true, "license": "MIT", "dependencies": { @@ -6896,8 +6922,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.5", - "vitest": "3.0.5" + "@vitest/browser": "3.0.6", + "vitest": "3.0.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6906,15 +6932,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", - "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz", + "integrity": "sha512-zBduHf/ja7/QRX4HdP1DSq5XrPgdN+jzLOwaTq/0qZjYfgETNFCKf9nOAp2j3hmom3oTbczuUzrzg9Hafh7hNg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.5", - "@vitest/utils": "3.0.5", - "chai": "^5.1.2", + "@vitest/spy": "3.0.6", + "@vitest/utils": "3.0.6", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -6922,13 +6948,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", - "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.6.tgz", + "integrity": "sha512-KPztr4/tn7qDGZfqlSPQoF2VgJcKxnDNhmfR3VgZ6Fy1bO8T9Fc1stUiTXtqz0yG24VpD00pZP5f8EOFknjNuQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.5", + "@vitest/spy": "3.0.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6959,9 +6985,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", - "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.6.tgz", + "integrity": "sha512-Zyctv3dbNL+67qtHfRnUE/k8qxduOamRfAL1BurEIQSyOEFffoMvx2pnDSSbKAAVxY0Ej2J/GH2dQKI0W2JyVg==", "dev": true, "license": "MIT", "dependencies": { @@ -6972,14 +6998,14 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", - "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.6.tgz", + "integrity": "sha512-JopP4m/jGoaG1+CBqubV/5VMbi7L+NQCJTu1J1Pf6YaUbk7bZtaq5CX7p+8sY64Sjn1UQ1XJparHfcvTTdu9cA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.5", - "pathe": "^2.0.2" + "@vitest/utils": "3.0.6", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -6993,15 +7019,15 @@ "license": "MIT" }, "node_modules/@vitest/snapshot": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", - "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.6.tgz", + "integrity": "sha512-qKSmxNQwT60kNwwJHMVwavvZsMGXWmngD023OHSgn873pV0lylK7dwBTfYP7e4URy5NiBCHHiQGA9DHkYkqRqg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.5", + "@vitest/pretty-format": "3.0.6", "magic-string": "^0.30.17", - "pathe": "^2.0.2" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -7015,9 +7041,9 @@ "license": "MIT" }, "node_modules/@vitest/spy": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", - "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.6.tgz", + "integrity": "sha512-HfOGx/bXtjy24fDlTOpgiAEJbRfFxoX3zIGagCqACkFKKZ/TTOE6gYMKXlqecvxEndKFuNHcHqP081ggZ2yM0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7028,14 +7054,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", - "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.6.tgz", + "integrity": "sha512-18ktZpf4GQFTbf9jK543uspU03Q2qya7ZGya5yiZ0Gx0nnnalBvd5ZBislbl2EhLjM8A8rt4OilqKG7QwcGkvQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.5", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.6", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -8746,9 +8772,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.101", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.101.tgz", - "integrity": "sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA==", + "version": "1.5.102", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz", + "integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -10093,9 +10119,9 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -10198,12 +10224,12 @@ } }, "node_modules/framer-motion": { - "version": "12.4.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.3.tgz", - "integrity": "sha512-rsMeO7w3dKyNG09o3cGwSH49iHU+VgDmfSSfsX+wfkO3zDA6WWkh4sUsMXd155YROjZP+7FTIhDrBYfgZeHjKQ==", + "version": "12.4.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.4.tgz", + "integrity": "sha512-JWkVwbJBgVkeZHNcnk8ififgwTF+5de9wbJnTLI+g9YqaGo75Xd5uRVDm9FR8chqRDOKcXv/71f40CGescYVmg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.0.0", + "motion-dom": "^12.4.4", "motion-utils": "^12.0.0", "tslib": "^2.4.0" }, @@ -11752,9 +11778,9 @@ } }, "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -12324,7 +12350,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -13503,9 +13528,9 @@ } }, "node_modules/motion-dom": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.0.0.tgz", - "integrity": "sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==", + "version": "12.4.4", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.4.tgz", + "integrity": "sha512-D8Kjp8oqUNqxoAVmIlOH+YCMov/4koBAmG4OJs0VWfh18xkQEIsx9+S7yrXyx0XaMBEPtre6e9LiSW2Zs7vIhA==", "license": "MIT", "dependencies": { "motion-utils": "^12.0.0" @@ -13527,9 +13552,9 @@ } }, "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "license": "MIT", "engines": { "node": ">=10" @@ -14534,9 +14559,9 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.219.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.219.0.tgz", - "integrity": "sha512-RnjtcjI4UYTBsjfF4Fs1lICWmGjiqMU9H0fN2ab1BEcDOFL/2m9Fx/1viCxvMiQR8cmgWWpkipJXD0gY7czDOA==", + "version": "1.219.3", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.219.3.tgz", + "integrity": "sha512-oKN4no9RRAptZ86R/MvMjsxQnFAe97rwU2emmTzf/q9ng+7V4nU+APM0ItzrESFtRYx1X8kKtxDUlkujNhfMvw==", "license": "MIT", "dependencies": { "core-js": "^3.38.1", @@ -14552,9 +14577,9 @@ "license": "Apache-2.0" }, "node_modules/preact": { - "version": "10.26.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.0.tgz", - "integrity": "sha512-6ugi/Mb7lyV5RA6KlnijFyDLMU253i7L0RRiObIzDoqj59KT9iTeNJbA/YGw6M7jP4vxaab0DOA8DgodTOA6EQ==", + "version": "10.26.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.2.tgz", + "integrity": "sha512-0gNmv4qpS9HaN3+40CLBAnKe0ZfyE4ZWo5xKlC1rVrr0ckkEvJvAQqKaHANdFKsGstoxrY4AItZ7kZSGVoVjgg==", "license": "MIT", "funding": { "type": "opencollective", @@ -14679,7 +14704,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -14691,7 +14715,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/property-information": { @@ -14883,9 +14906,9 @@ } }, "node_modules/react-i18next": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz", - "integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", + "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", @@ -15999,9 +16022,9 @@ } }, "node_modules/sirv": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", - "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -16013,9 +16036,9 @@ } }, "node_modules/sirv-cli": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.0.tgz", - "integrity": "sha512-p88yHl8DmTOUJroRiW2o9ezJc/YRLxphBydX2NGQc3naKBA09B3EM4Q/yaN8FYF0e50fRSZP7dyatr72b1u5Jw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz", + "integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==", "license": "MIT", "dependencies": { "console-clear": "^1.1.0", @@ -16572,6 +16595,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.6.0.tgz", + "integrity": "sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/style-to-object": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", @@ -17692,31 +17729,31 @@ } }, "node_modules/vitest": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", - "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.6.tgz", + "integrity": "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.5", - "@vitest/mocker": "3.0.5", - "@vitest/pretty-format": "^3.0.5", - "@vitest/runner": "3.0.5", - "@vitest/snapshot": "3.0.5", - "@vitest/spy": "3.0.5", - "@vitest/utils": "3.0.5", - "chai": "^5.1.2", + "@vitest/expect": "3.0.6", + "@vitest/mocker": "3.0.6", + "@vitest/pretty-format": "^3.0.6", + "@vitest/runner": "3.0.6", + "@vitest/snapshot": "3.0.6", + "@vitest/spy": "3.0.6", + "@vitest/utils": "3.0.6", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.2", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.5", + "vite-node": "3.0.6", "why-is-node-running": "^2.3.0" }, "bin": { @@ -17732,8 +17769,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.5", - "@vitest/ui": "3.0.5", + "@vitest/browser": "3.0.6", + "@vitest/ui": "3.0.6", "happy-dom": "*", "jsdom": "*" }, @@ -17769,16 +17806,16 @@ "license": "MIT" }, "node_modules/vitest/node_modules/vite-node": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", - "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.6.tgz", + "integrity": "sha512-s51RzrTkXKJrhNbUzQRsarjmAae7VmMPAsRT7lppVpIg6mK3zGthP9Hgz0YQQKuNcF+Ii7DfYk3Fxz40jRmePw==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.2", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index 6049777c09df..b840a3e51418 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,26 +13,28 @@ "@react-router/serve": "^7.1.5", "@react-types/shared": "^3.27.0", "@reduxjs/toolkit": "^2.5.1", - "@tanstack/react-query": "^5.66.0", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.5.0", + "@tanstack/react-query": "^5.66.7", "@vitejs/plugin-react": "^4.3.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.7.9", "clsx": "^2.1.1", "eslint-config-airbnb-typescript": "^18.0.0", - "framer-motion": "^12.4.2", + "framer-motion": "^12.4.4", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.3", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.22", - "jose": "^5.9.4", + "jose": "^5.10.0", "monaco-editor": "^0.52.2", - "posthog-js": "^1.217.6", + "posthog-js": "^1.219.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-highlight": "^0.15.0", "react-hot-toast": "^2.5.1", - "react-i18next": "^15.4.0", + "react-i18next": "^15.4.1", "react-icons": "^5.4.0", "react-markdown": "^9.0.3", "react-redux": "^9.2.0", @@ -40,7 +42,7 @@ "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.7", "remark-gfm": "^4.0.1", - "sirv-cli": "^3.0.0", + "sirv-cli": "^3.0.1", "socket.io-client": "^4.8.1", "tailwind-merge": "^3.0.1", "vite": "^6.1.0", @@ -49,7 +51,8 @@ }, "scripts": { "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev", - "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev", + "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev", + "dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev", "build": "npm run make-i18n && npm run typecheck && react-router build", "start": "npx sirv-cli build/ --single", "test": "vitest run", @@ -93,7 +96,7 @@ "@types/ws": "^8.5.14", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", - "@vitest/coverage-v8": "^3.0.5", + "@vitest/coverage-v8": "^3.0.6", "autoprefixer": "^10.4.20", "cross-env": "^7.0.3", "eslint": "^8.57.0", @@ -111,6 +114,7 @@ "msw": "^2.6.6", "postcss": "^8.5.2", "prettier": "^3.5.1", + "stripe": "^17.5.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index e77d7a5527c2..58abf00956aa 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -274,6 +274,23 @@ class OpenHands { return data.status === 200; } + static async createCheckoutSession(amount: number): Promise { + const { data } = await openHands.post( + "/api/billing/create-checkout-session", + { + amount, + }, + ); + return data.redirect_url; + } + + static async getBalance(): Promise { + const { data } = await openHands.get<{ credits: string }>( + "/api/billing/credits", + ); + return data.credits; + } + static async getGitHubUser(): Promise { const response = await openHands.get("/api/github/user"); diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx index 8be19387f52f..288dd7728705 100644 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx @@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({ {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx new file mode 100644 index 000000000000..e42a9e7894d0 --- /dev/null +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; +import { useBalance } from "#/hooks/query/use-balance"; +import { cn } from "#/utils/utils"; +import MoneyIcon from "#/icons/money.svg?react"; +import { SettingsInput } from "../settings/settings-input"; +import { BrandButton } from "../settings/brand-button"; +import { HelpLink } from "../settings/help-link"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { amountIsValid } from "#/utils/amount-is-valid"; + +export function PaymentForm() { + const { data: balance, isLoading } = useBalance(); + const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession(); + + const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true); + + const billingFormAction = async (formData: FormData) => { + const amount = formData.get("top-up-input")?.toString(); + + if (amount?.trim()) { + if (!amountIsValid(amount)) return; + + const float = parseFloat(amount); + addBalance({ amount: Number(float.toFixed(2)) }); + } + + setButtonIsDisabled(true); + }; + + const handleTopUpInputChange = (value: string) => { + setButtonIsDisabled(!amountIsValid(value)); + }; + + return ( +
+

+ Manage Credits +

+ +
+
+ + Balance +
+ {!isLoading && ( + ${Number(balance).toFixed(2)} + )} + {isLoading && } +
+ +
+ + +
+ + Add credit + + {isPending && } +
+
+ + + + ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx index 5362af09aa28..5d737fd991dd 100644 --- a/frontend/src/components/features/settings/settings-input.tsx +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -12,6 +12,7 @@ interface SettingsInputProps { isDisabled?: boolean; startContent?: React.ReactNode; className?: string; + onChange?: (value: string) => void; } export function SettingsInput({ @@ -25,6 +26,7 @@ export function SettingsInput({ isDisabled, startContent, className, + onChange, }: SettingsInputProps) { return (