diff --git a/apps/deploy-web/playwright.config.ts b/apps/deploy-web/playwright.config.ts index c7e81e8d8a..c0d85ceabf 100644 --- a/apps/deploy-web/playwright.config.ts +++ b/apps/deploy-web/playwright.config.ts @@ -7,6 +7,8 @@ import { getUserAgent } from "./tests/ui/fixture/user-agent"; // for process.env dotenv.config({ path: path.resolve(__dirname, "env/.env.test.local") }); dotenv.config({ path: path.resolve(__dirname, "env/.env.test") }); +const slowMo = Number(process.env.PW_SLOW_MO) || 0; + /** * See https://playwright.dev/docs/test-configuration. */ @@ -27,9 +29,10 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "retain-on-failure", - video: "retain-on-failure", + video: slowMo > 0 ? "on" : "retain-on-failure", actionTimeout: 15_000, - permissions: ["clipboard-read", "clipboard-write"] + permissions: ["clipboard-read", "clipboard-write"], + launchOptions: { slowMo } }, /* Configure projects for major browsers */ diff --git a/apps/deploy-web/src/components/auth/AddCreditsSheet/AddCreditsSheet.spec.tsx b/apps/deploy-web/src/components/auth/AddCreditsSheet/AddCreditsSheet.spec.tsx new file mode 100644 index 0000000000..4e3bd65f50 --- /dev/null +++ b/apps/deploy-web/src/components/auth/AddCreditsSheet/AddCreditsSheet.spec.tsx @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { AddCreditsSheet, DEPENDENCIES } from "./AddCreditsSheet"; + +import { act, render } from "@testing-library/react"; +import { MockComponents } from "@tests/unit/mocks"; + +describe(AddCreditsSheet.name, () => { + it("renders the AddCreditsForm when open", () => { + const { dependencies } = setup({ open: true }); + + expect(dependencies.AddCreditsForm).toHaveBeenCalled(); + }); + + it("does not render the AddCreditsForm while closed", () => { + const { dependencies } = setup({ open: false }); + + expect(dependencies.AddCreditsForm).not.toHaveBeenCalled(); + }); + + it("forwards onDone to the form", () => { + const onDone = vi.fn(); + const { dependencies } = setup({ open: true, onDone }); + + expect(dependencies.AddCreditsForm).toHaveBeenCalledWith(expect.objectContaining({ onDone }), expect.anything()); + }); + + it("forwards isWalletReady to the form", () => { + const { dependencies } = setup({ open: true, isWalletReady: false }); + + expect(dependencies.AddCreditsForm).toHaveBeenCalledWith(expect.objectContaining({ isWalletReady: false }), expect.anything()); + }); + + it("blocks closing while the form reports a payment in progress", () => { + const onOpenChange = vi.fn(); + const { dependencies } = setup({ open: true, onOpenChange, dependencies: { AddCreditsForm: reportingForm(true) } }); + + act(() => dependencies.Sheet.mock.calls.at(-1)![0].onOpenChange?.(false)); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("allows closing when no payment is in progress", () => { + const onOpenChange = vi.fn(); + const { dependencies } = setup({ open: true, onOpenChange, dependencies: { AddCreditsForm: reportingForm(false) } }); + + act(() => dependencies.Sheet.mock.calls.at(-1)![0].onOpenChange?.(false)); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("hides the close button while the form reports a payment in progress", () => { + const { dependencies } = setup({ open: true, dependencies: { AddCreditsForm: reportingForm(true) } }); + + expect(dependencies.SheetContent.mock.calls.at(-1)![0].hideCloseButton).toBe(true); + }); + + function reportingForm(isProcessing: boolean) { + return ({ onProcessingChange }: Parameters[0]) => { + useEffect(() => onProcessingChange?.(isProcessing), [onProcessingChange]); + return <>; + }; + } + + function setup(input: { + open: boolean; + onOpenChange?: (open: boolean) => void; + onDone?: (amount: number, organization?: string) => void; + isWalletReady?: boolean; + dependencies?: Partial; + }) { + const dependencies = MockComponents(DEPENDENCIES, input.dependencies); + + render( + + ); + + return { dependencies }; + } +}); diff --git a/apps/deploy-web/src/components/auth/AddCreditsSheet/AddCreditsSheet.tsx b/apps/deploy-web/src/components/auth/AddCreditsSheet/AddCreditsSheet.tsx new file mode 100644 index 0000000000..076bd911e9 --- /dev/null +++ b/apps/deploy-web/src/components/auth/AddCreditsSheet/AddCreditsSheet.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React, { useState } from "react"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@akashnetwork/ui/components"; + +import { AddCreditsForm } from "@src/components/billing-usage/AddCreditsForm/AddCreditsForm"; + +export const DEPENDENCIES = { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + AddCreditsForm +}; + +interface AddCreditsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onDone: (amount: number, organization?: string) => void; + isWalletReady?: boolean; + dependencies?: typeof DEPENDENCIES; +} + +export function AddCreditsSheet({ open, onOpenChange, onDone, isWalletReady, dependencies: d = DEPENDENCIES }: AddCreditsSheetProps) { + const [isProcessing, setIsProcessing] = useState(false); + + const requestOpenChange = (next: boolean) => { + if (!next && isProcessing) return; + onOpenChange(next); + }; + + return ( + + + + Add credits + + This template needs a top-tier GPU, which isn't covered by your free trial. Add credits to unlock high-end GPUs, longer runtimes, and the full + Console. + + + + {open && } + + + ); +} diff --git a/apps/deploy-web/src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields.spec.tsx b/apps/deploy-web/src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields.spec.tsx new file mode 100644 index 0000000000..1568584793 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields.spec.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AddCreditsAmountValue } from "./AddCreditsAmountFields"; +import { AddCreditsAmountFields } from "./AddCreditsAmountFields"; + +import { fireEvent, render, screen } from "@testing-library/react"; + +describe(AddCreditsAmountFields.name, () => { + it("emits the chosen predefined amount and clears the custom amount", () => { + const { onChange } = setup({ value: { predefinedAmount: "", customAmount: "75" } }); + + fireEvent.click(screen.getByRole("radio", { name: /100/i })); + + expect(onChange).toHaveBeenCalledWith({ predefinedAmount: "100", customAmount: "" }); + }); + + it("emits the custom amount and clears the predefined amount", () => { + const { onChange } = setup({ value: { predefinedAmount: "100", customAmount: "" } }); + + fireEvent.change(screen.getByLabelText("custom-amount"), { target: { value: "42" } }); + + expect(onChange).toHaveBeenCalledWith({ predefinedAmount: "", customAmount: "42" }); + }); + + function setup(input: { value: AddCreditsAmountValue }) { + const onChange = vi.fn(); + render(); + return { onChange }; + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields.tsx b/apps/deploy-web/src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields.tsx new file mode 100644 index 0000000000..4bd59db023 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { ChangeEventHandler } from "react"; +import React, { useCallback } from "react"; +import { Field, FieldContent, FieldLabel, FieldTitle, Input, RadioGroup, RadioGroupItem } from "@akashnetwork/ui/components"; + +export interface AddCreditsAmountValue { + predefinedAmount?: string; + customAmount: string; +} + +interface AddCreditsAmountFieldsProps { + value: AddCreditsAmountValue; + onChange: (value: AddCreditsAmountValue) => void; +} + +export function AddCreditsAmountFields({ value, onChange }: AddCreditsAmountFieldsProps) { + const changePredefinedAmount = useCallback( + (predefinedAmount: string) => { + onChange({ predefinedAmount, customAmount: "" }); + }, + [onChange] + ); + + const changeCustomAmount: ChangeEventHandler = useCallback( + e => { + onChange({ customAmount: e.target.value, predefinedAmount: "" }); + }, + [onChange] + ); + + return ( +
+

Credit amount

+ +
+

Choose your amount

+ + + + + + 50 + + + + + + + + 100 + + + + + + + + 500 + + + + +
+ + + + Or enter custom amount (minimum 20) + + + +
+ ); +} diff --git a/apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.spec.tsx b/apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.spec.tsx new file mode 100644 index 0000000000..caa2342b6c --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.spec.tsx @@ -0,0 +1,418 @@ +import React, { useImperativeHandle } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { AddCreditsAmountFields } from "../AddCreditsAmountFields/AddCreditsAmountFields"; +import type { PaymentMethodSourceHandle } from "../AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields"; +import type { DEPENDENCIES } from "./AddCreditsForm"; +import { AddCreditsForm } from "./AddCreditsForm"; + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +describe(AddCreditsForm.name, () => { + it("creates a setup intent on mount", () => { + const mutate = vi.fn(); + setup({ status: "idle", mutate }); + + expect(mutate).toHaveBeenCalledTimes(1); + }); + + it("does not re-create the setup intent once it has been requested", () => { + const mutate = vi.fn(); + setup({ status: "pending", mutate }); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it("renders the first-purchase match alert", () => { + setup({ status: "idle" }); + + expect(screen.getByText(/first-purchase match/i)).toBeInTheDocument(); + }); + + it("passes a loading flag to the payment-method fields while the setup intent is being prepared", () => { + const { Mock: AddCreditsNewPaymentMethodFields, lastProps } = makePaymentMethodFieldsMock(); + setup({ status: "pending", dependencies: { AddCreditsNewPaymentMethodFields } }); + + expect(lastProps()!.isLoading).toBe(true); + }); + + it("disables the submit button until an amount is chosen", () => { + setup({ status: "success", clientSecret: "seti_secret" }); + + expect(screen.getByRole("button", { name: /purchase credits/i })).toBeDisabled(); + }); + + it("on submit: adds the payment method, charges via confirmPayment, and starts polling without calling onDone yet", async () => { + const confirmPayment = vi.fn().mockResolvedValue({ success: true }); + const pollForPayment = vi.fn(); + const onDone = vi.fn(); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_1", organization: "Acme" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + pollForPayment, + onDone, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: /100/i })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + expect(addPaymentMethod).toHaveBeenCalledTimes(1); + expect(confirmPayment).toHaveBeenCalledWith({ userId: "user_1", paymentMethodId: "pm_1", amount: 100, currency: "usd" }); + expect(pollForPayment).toHaveBeenCalledTimes(1); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("calls onDone after polling stops and the user is no longer trialing", async () => { + const confirmPayment = vi.fn().mockResolvedValue({ success: true }); + const onDone = vi.fn(); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_1", organization: "Acme" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + const { rerender } = setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + onDone, + isTrialing: true, + isPolling: false, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: /100/i })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + rerender({ status: "success", clientSecret: "seti_secret", confirmPayment, onDone, isTrialing: true, isPolling: true }); + expect(onDone).not.toHaveBeenCalled(); + + rerender({ status: "success", clientSecret: "seti_secret", confirmPayment, onDone, isTrialing: false, isPolling: false }); + + await waitFor(() => expect(onDone).toHaveBeenCalledWith(100, "Acme")); + }); + + it("shows an error when polling stops without the trial flipping", async () => { + const confirmPayment = vi.fn().mockResolvedValue({ success: true }); + const onDone = vi.fn(); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_1", organization: "Acme" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + const { rerender } = setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + onDone, + isTrialing: true, + isPolling: false, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: /100/i })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + rerender({ status: "success", clientSecret: "seti_secret", confirmPayment, onDone, isTrialing: true, isPolling: true }); + rerender({ status: "success", clientSecret: "seti_secret", confirmPayment, onDone, isTrialing: true, isPolling: false }); + + expect(await screen.findByText(/payment did not complete in time/i)).toBeInTheDocument(); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("defers confirmPayment until the wallet is ready", async () => { + const confirmPayment = vi.fn().mockResolvedValue({ success: true }); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_1", organization: "Acme" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + const { rerender } = setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + isWalletReady: false, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: /100/i })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + expect(addPaymentMethod).toHaveBeenCalledTimes(1); + expect(confirmPayment).not.toHaveBeenCalled(); + + await act(async () => { + rerender({ status: "success", clientSecret: "seti_secret", confirmPayment, isWalletReady: true }); + }); + + await waitFor(() => expect(confirmPayment).toHaveBeenCalledWith({ userId: "user_1", paymentMethodId: "pm_1", amount: 100, currency: "usd" })); + }); + + it("does not call the API when addPaymentMethod resolves null", async () => { + const confirmPayment = vi.fn(); + const onDone = vi.fn(); + const addPaymentMethod = vi.fn().mockResolvedValue(null); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + onDone, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: "50" })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + expect(addPaymentMethod).toHaveBeenCalledTimes(1); + expect(confirmPayment).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("hands off to 3D Secure when the charge requires action and does not call onDone yet", async () => { + const confirmPayment = vi.fn().mockResolvedValue({ + requiresAction: true, + clientSecret: "pi_secret", + paymentIntentId: "pi_1" + }); + const start3DSecure = vi.fn(); + const onDone = vi.fn(); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_3ds" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + start3DSecure, + onDone, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: "50" })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + expect(start3DSecure).toHaveBeenCalledWith({ + clientSecret: "pi_secret", + paymentIntentId: "pi_1", + paymentMethodId: "pm_3ds" + }); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("surfaces a stripe-handled error when the charge throws", async () => { + const confirmPayment = vi.fn().mockRejectedValue(new Error("boom")); + const handleStripeError = vi.fn().mockReturnValue({ message: "Card declined.", userAction: "Try a different card." }); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_x" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + handleStripeError, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: "50" })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + expect(handleStripeError).toHaveBeenCalled(); + expect(await screen.findByText("Card declined.")).toBeInTheDocument(); + expect(screen.getByText("Try a different card.")).toBeInTheDocument(); + }); + + it("does not submit when no amount is chosen", async () => { + const confirmPayment = vi.fn(); + const addPaymentMethod = vi.fn(); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + await waitFor(() => expect(addPaymentMethod).not.toHaveBeenCalled()); + expect(confirmPayment).not.toHaveBeenCalled(); + }); + + it("does not submit when the custom amount is below the 20-credit minimum", async () => { + const confirmPayment = vi.fn(); + const addPaymentMethod = vi.fn(); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.change(screen.getByLabelText(/custom-amount/i), { target: { value: "19" } }); + + expect(screen.getByRole("button", { name: /purchase credits/i })).toBeDisabled(); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + await waitFor(() => expect(addPaymentMethod).not.toHaveBeenCalled()); + expect(confirmPayment).not.toHaveBeenCalled(); + }); + + it("reports processing to the parent once a charge is underway", async () => { + const onProcessingChange = vi.fn(); + const confirmPayment = vi.fn().mockResolvedValue({ success: true }); + const addPaymentMethod = vi.fn().mockResolvedValue({ paymentMethodId: "pm_1" }); + const { Mock: AddCreditsNewPaymentMethodFields } = makePaymentMethodFieldsMock(addPaymentMethod); + + setup({ + status: "success", + clientSecret: "seti_secret", + confirmPayment, + onProcessingChange, + dependencies: { AddCreditsNewPaymentMethodFields } + }); + + fireEvent.click(screen.getByRole("radio", { name: "50" })); + + await act(async () => { + fireEvent.submit(screen.getByRole("button", { name: /purchase credits/i }).closest("form")!); + }); + + expect(onProcessingChange).toHaveBeenCalledWith(true); + }); + + function makePaymentMethodFieldsMock(addPaymentMethod: PaymentMethodSourceHandle["addPaymentMethod"] = vi.fn().mockResolvedValue(null)) { + const propsLog: Array<{ clientSecret?: string; isLoading: boolean }> = []; + const Mock: typeof DEPENDENCIES.AddCreditsNewPaymentMethodFields = React.forwardRef< + PaymentMethodSourceHandle, + { clientSecret?: string; isLoading: boolean } + >(function MockPaymentMethodFields(props, ref) { + propsLog.push(props); + useImperativeHandle(ref, () => ({ addPaymentMethod }), [addPaymentMethod]); + return null; + }); + + return { + Mock, + lastProps: () => propsLog.at(-1) + }; + } + + function setup(input: SetupInput) { + const result = render(buildElement(input)); + return { + ...result, + rerender(next: SetupInput) { + result.rerender(buildElement({ ...next, dependencies: { ...input.dependencies, ...next.dependencies } })); + } + }; + } + + type SetupInput = { + status: ReturnType["status"]; + clientSecret?: string; + mutate?: ReturnType["mutate"]; + confirmPayment?: ReturnType["confirmPayment"]["mutateAsync"]; + pollForPayment?: ReturnType["pollForPayment"]; + start3DSecure?: ReturnType["start3DSecure"]; + handleStripeError?: typeof DEPENDENCIES.handleStripeError; + onDone?: (amount: number, organization?: string) => void; + onProcessingChange?: (isProcessing: boolean) => void; + isTrialing?: boolean; + isPolling?: boolean; + isWalletReady?: boolean; + dependencies?: Partial; + }; + + function buildElement(input: SetupInput) { + const useSetupIntentMutation: typeof DEPENDENCIES.useSetupIntentMutation = () => + ({ + data: input.clientSecret ? { clientSecret: input.clientSecret } : undefined, + mutate: input.mutate ?? vi.fn(), + reset: vi.fn(), + status: input.status + }) as unknown as ReturnType; + + const useUser: typeof DEPENDENCIES.useUser = () => + mock>({ + user: { userId: "user_1", id: "user_1" } as ReturnType["user"] + }); + + const usePaymentMutations: typeof DEPENDENCIES.usePaymentMutations = () => + mock>({ + confirmPayment: mock["confirmPayment"]>({ + mutateAsync: input.confirmPayment ?? vi.fn() + }) + }); + + const usePaymentPolling: typeof DEPENDENCIES.usePaymentPolling = () => + mock>({ + pollForPayment: input.pollForPayment ?? vi.fn(), + stopPolling: vi.fn(), + isPolling: input.isPolling ?? false + }); + + const useWallet: typeof DEPENDENCIES.useWallet = () => mock>({ isTrialing: input.isTrialing ?? false }); + + const use3DSecure: typeof DEPENDENCIES.use3DSecure = () => + mock>({ + isOpen: false, + threeDSData: null, + isLoading: false, + start3DSecure: input.start3DSecure ?? vi.fn(), + close3DSecure: vi.fn(), + handle3DSSuccess: vi.fn(), + handle3DSError: vi.fn() + }); + + return ( + null, + useSetupIntentMutation, + useUser, + usePaymentMutations, + usePaymentPolling, + useWallet, + use3DSecure, + handleStripeError: input.handleStripeError ?? (() => ({ message: "fallback", userAction: undefined })), + ...input.dependencies + }} + /> + ); + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx b/apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx new file mode 100644 index 0000000000..ad18023fda --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx @@ -0,0 +1,247 @@ +"use client"; + +import type { FormEventHandler } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, AlertDescription, AlertTitle, Button, Spinner } from "@akashnetwork/ui/components"; +import { GiftIcon } from "lucide-react"; + +import type { AddCreditsAmountValue } from "@src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields"; +import { AddCreditsAmountFields } from "@src/components/billing-usage/AddCreditsAmountFields/AddCreditsAmountFields"; +import type { PaymentMethodSourceHandle } from "@src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields"; +import { AddCreditsNewPaymentMethodFields } from "@src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields"; +import { ThreeDSecurePopup } from "@src/components/shared/PaymentMethodForm/ThreeDSecurePopup"; +import { usePaymentPolling } from "@src/context/PaymentPollingProvider"; +import { useWallet } from "@src/context/WalletProvider"; +import { use3DSecure } from "@src/hooks/use3DSecure"; +import { useUser } from "@src/hooks/useUser"; +import { usePaymentMutations, useSetupIntentMutation } from "@src/queries"; +import { handleStripeError } from "@src/utils/stripeErrorHandler"; + +/** Smallest credit purchase Stripe accepts; mirrors the `min` on the custom-amount input. */ +const MIN_AMOUNT = 20; + +export const DEPENDENCIES = { + AddCreditsAmountFields, + AddCreditsNewPaymentMethodFields, + ThreeDSecurePopup, + useSetupIntentMutation, + usePaymentMutations, + usePaymentPolling, + useWallet, + use3DSecure, + useUser, + handleStripeError +}; + +interface AddCreditsFormProps { + onDone: (amount: number, organization?: string) => void; + isWalletReady?: boolean; + onProcessingChange?: (isProcessing: boolean) => void; + dependencies?: typeof DEPENDENCIES; +} + +interface PendingCharge { + paymentMethodId: string; + organization?: string; + amount: number; + wasTrialing: boolean; + status: "pending" | "charging"; +} + +/** + * Orchestrates the Add Credits flow: collecting a payment method is always + * allowed, but the charge waits until the managed wallet is ready. On submit it + * asks the swappable child for a payment method and stores it as a pending + * charge; a reactive effect fires confirmPayment once the wallet is ready, + * hands off to the 3D Secure popup when required, then waits for payment + * polling to settle before notifying the caller through onDone. + */ +export function AddCreditsForm({ onDone, isWalletReady = true, onProcessingChange, dependencies: d = DEPENDENCIES }: AddCreditsFormProps) { + const { data: setupIntent, mutate: createSetupIntent, status: setupIntentStatus } = d.useSetupIntentMutation(); + const { user } = d.useUser(); + const { pollForPayment, isPolling } = d.usePaymentPolling(); + const { isTrialing } = d.useWallet(); + const { + confirmPayment: { mutateAsync: confirmPayment } + } = d.usePaymentMutations(); + + const [amountInput, setAmountInput] = useState({ predefinedAmount: undefined, customAmount: "" }); + const [charge, setCharge] = useState(null); + const [error, setError] = useState(null); + const [errorAction, setErrorAction] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + const paymentMethodRef = useRef(null); + const wasPollingRef = useRef(false); + + const amount = useMemo(() => Number(amountInput.predefinedAmount || amountInput.customAmount) || 0, [amountInput.predefinedAmount, amountInput.customAmount]); + const isSetupLoading = setupIntentStatus === "pending" || setupIntentStatus === "idle"; + + useEffect( + function createSetupIntentOnMount() { + if (setupIntentStatus === "idle") { + createSetupIntent(); + } + }, + [setupIntentStatus, createSetupIntent] + ); + + const submit: FormEventHandler = async e => { + e.preventDefault(); + + if (!user?.id || amount < MIN_AMOUNT) { + return; + } + + setError(null); + setErrorAction(null); + setIsProcessing(true); + + const paymentMethod = await paymentMethodRef.current?.addPaymentMethod(); + if (!paymentMethod) { + setIsProcessing(false); + return; + } + + setCharge({ + paymentMethodId: paymentMethod.paymentMethodId, + organization: paymentMethod.organization, + amount, + wasTrialing: isTrialing, + status: "pending" + }); + }; + + const finalizeFailure = useCallback((message: string, userAction?: string | null) => { + setCharge(null); + setIsProcessing(false); + setError(message); + setErrorAction(userAction ?? null); + }, []); + + const threeDSecure = d.use3DSecure({ + onSuccess: function onThreeDSecureSuccess() { + pollForPayment(); + }, + onError: function onThreeDSecureError(message) { + finalizeFailure(message); + }, + showSuccessMessage: false + }); + + const performCharge = useCallback( + async function performCharge(pending: PendingCharge) { + if (!user?.id) return; + + try { + const chargeResult = await confirmPayment({ + userId: user.id, + paymentMethodId: pending.paymentMethodId, + amount: pending.amount, + currency: "usd" + }); + + if (chargeResult.requiresAction && chargeResult.clientSecret && chargeResult.paymentIntentId) { + threeDSecure.start3DSecure({ + clientSecret: chargeResult.clientSecret, + paymentIntentId: chargeResult.paymentIntentId, + paymentMethodId: pending.paymentMethodId + }); + return; + } + + if (chargeResult.success) { + pollForPayment(); + return; + } + + finalizeFailure("Payment failed. Please try again."); + } catch (err) { + const stripeError = d.handleStripeError(err); + finalizeFailure(stripeError.message, stripeError.userAction); + } + }, + [user?.id, confirmPayment, threeDSecure, pollForPayment, finalizeFailure, d] + ); + + useEffect( + function chargeWhenWalletReady() { + if (!charge || charge.status !== "pending" || charge.amount < MIN_AMOUNT || !isWalletReady) return; + + setCharge({ ...charge, status: "charging" }); + void performCharge(charge); + }, + [charge, isWalletReady, performCharge] + ); + + useEffect( + function completeWhenPollingSettles() { + const wasPolling = wasPollingRef.current; + wasPollingRef.current = isPolling; + + if (!wasPolling || isPolling || !charge) return; + + if (charge.wasTrialing && isTrialing) { + finalizeFailure("Payment did not complete in time. Please try again."); + return; + } + + setCharge(null); + setIsProcessing(false); + onDone(charge.amount, charge.organization); + }, + [isPolling, isTrialing, charge, finalizeFailure, onDone] + ); + + useEffect( + function reportProcessing() { + onProcessingChange?.(isProcessing); + }, + [isProcessing, onProcessingChange] + ); + + return ( + <> + + + First-purchase match. + Akash matches your first purchase dollar-dollar, up to $100. + + +
+ + + + + {error && ( + + + {error} + {errorAction && {errorAction}} + + + )} + + + + + {threeDSecure.threeDSData?.clientSecret && ( + + )} + + ); +} diff --git a/apps/deploy-web/src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields.spec.tsx b/apps/deploy-web/src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields.spec.tsx new file mode 100644 index 0000000000..b0129b26b0 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields.spec.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { DEPENDENCIES, PaymentMethodSourceHandle } from "./AddCreditsNewPaymentMethodFields"; +import { AddCreditsNewPaymentMethodFields } from "./AddCreditsNewPaymentMethodFields"; + +import { act, fireEvent, render, screen } from "@testing-library/react"; + +describe(AddCreditsNewPaymentMethodFields.name, () => { + it("shows a loading spinner when isLoading is true", () => { + setup({ isLoading: true }); + + expect(screen.getByRole("status", { name: /preparing payment form/i })).toBeInTheDocument(); + expect(screen.queryByTestId("stripe-elements")).not.toBeInTheDocument(); + }); + + it("renders nothing when no client secret is provided", () => { + const Elements = vi.fn(() =>
) as unknown as typeof DEPENDENCIES.Elements; + setup({ isLoading: false, dependencies: { Elements } }); + + expect(screen.queryByRole("status", { name: /preparing payment form/i })).not.toBeInTheDocument(); + expect(screen.queryByTestId("stripe-elements")).not.toBeInTheDocument(); + }); + + it("renders the Stripe Elements wrapper with billing fields when the client secret is ready", () => { + setup({ isLoading: false, clientSecret: "seti_secret" }); + + expect(screen.getByTestId("stripe-elements")).toBeInTheDocument(); + expect(screen.getByTestId("address-element")).toBeInTheDocument(); + expect(screen.getByTestId("payment-element")).toBeInTheDocument(); + expect(screen.getByLabelText("organization")).toBeInTheDocument(); + }); + + it("shows the unavailable message when Stripe cannot be loaded", () => { + const useServices: typeof DEPENDENCIES.useServices = () => + mock>({ + stripeService: mock["stripeService"]>({ + getStripe: () => null as unknown as ReturnType["stripeService"]["getStripe"]> + }) + }); + setup({ isLoading: false, clientSecret: "seti_secret", dependencies: { useServices } }); + + expect(screen.getByText(/Payment processing is not available at this time/i)).toBeInTheDocument(); + }); + + it("addPaymentMethod returns the Stripe payment method id and the typed organization", async () => { + const confirmSetup = vi.fn().mockResolvedValue({ setupIntent: { payment_method: "pm_123" } }); + const { ref } = setup({ + isLoading: false, + clientSecret: "seti_secret", + dependencies: { useStripe: () => ({ confirmSetup }) as unknown as ReturnType } + }); + + fireEvent.change(screen.getByLabelText("organization"), { target: { value: "Acme" } }); + + const result = await act(async () => ref.current!.addPaymentMethod()); + + expect(confirmSetup).toHaveBeenCalledWith({ elements: expect.anything(), redirect: "if_required" }); + expect(result).toEqual({ paymentMethodId: "pm_123", organization: "Acme" }); + }); + + it("omits organization when the field is left blank", async () => { + const confirmSetup = vi.fn().mockResolvedValue({ setupIntent: { payment_method: "pm_blank" } }); + const { ref } = setup({ + isLoading: false, + clientSecret: "seti_secret", + dependencies: { useStripe: () => ({ confirmSetup }) as unknown as ReturnType } + }); + + const result = await act(async () => ref.current!.addPaymentMethod()); + + expect(result).toEqual({ paymentMethodId: "pm_blank", organization: undefined }); + }); + + it("renders the confirmSetup error inline and returns null", async () => { + const confirmSetup = vi.fn().mockResolvedValue({ error: { message: "Your card was declined." } }); + const { ref } = setup({ + isLoading: false, + clientSecret: "seti_secret", + dependencies: { useStripe: () => ({ confirmSetup }) as unknown as ReturnType } + }); + + const result = await act(async () => ref.current!.addPaymentMethod()); + + expect(result).toBeNull(); + expect(await screen.findByText("Your card was declined.")).toBeInTheDocument(); + }); + + it("returns null and shows a generic error when no payment method id comes back", async () => { + const confirmSetup = vi.fn().mockResolvedValue({ setupIntent: { payment_method: null } }); + const { ref } = setup({ + isLoading: false, + clientSecret: "seti_secret", + dependencies: { useStripe: () => ({ confirmSetup }) as unknown as ReturnType } + }); + + const result = await act(async () => ref.current!.addPaymentMethod()); + + expect(result).toBeNull(); + expect(await screen.findByText(/couldn't save your card/i)).toBeInTheDocument(); + }); + + function setup(input: { isLoading: boolean; clientSecret?: string; dependencies?: Partial }) { + const Elements = (({ children }: { children?: React.ReactNode }) => ( +
{children}
+ )) as unknown as typeof DEPENDENCIES.Elements; + + const useServices: typeof DEPENDENCIES.useServices = () => + mock>({ + stripeService: mock["stripeService"]>({ + getStripe: () => Promise.resolve(mock["stripeService"]["getStripe"]>>>()) + }) + }); + + const useTheme: typeof DEPENDENCIES.useTheme = () => mock>({ resolvedTheme: "light" }); + + const useStripe: typeof DEPENDENCIES.useStripe = () => mock>(); + const useElements: typeof DEPENDENCIES.useElements = () => mock>(); + + const ref = React.createRef(); + + render( +
) as unknown as typeof DEPENDENCIES.AddressElement, + PaymentElement: (() =>
) as unknown as typeof DEPENDENCIES.PaymentElement, + ...input.dependencies + }} + /> + ); + + return { ref }; + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields.tsx b/apps/deploy-web/src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields.tsx new file mode 100644 index 0000000000..b67c01c266 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddCreditsNewPaymentMethodFields/AddCreditsNewPaymentMethodFields.tsx @@ -0,0 +1,192 @@ +"use client"; + +import React, { forwardRef, useImperativeHandle, useMemo, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { Alert, AlertDescription, Field, FieldLabel, Input, Spinner } from "@akashnetwork/ui/components"; +import { AddressElement, Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"; +import { ShieldCheck } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { useServices } from "@src/context/ServicesProvider/ServicesProvider"; + +/** + * Imperative contract exposed by a payment-method source so an orchestrator + * (e.g. AddCreditsForm) can trigger payment-method creation without lifting + * Stripe Elements context out of the child component. + */ +export interface PaymentMethodSourceHandle { + /** + * Confirms the Stripe SetupIntent and returns the new payment method id + * (with optional organization label), or null if confirmation failed — + * in which case the source has already surfaced the error inline. + */ + addPaymentMethod(): Promise<{ paymentMethodId: string; organization?: string } | null>; +} + +export const DEPENDENCIES = { + Elements, + useServices, + useTheme, + useStripe, + useElements, + AddressElement, + PaymentElement +}; + +interface AddCreditsNewPaymentMethodFieldsProps { + clientSecret?: string; + isLoading: boolean; + dependencies?: typeof DEPENDENCIES; +} + +export const AddCreditsNewPaymentMethodFields = forwardRef( + function AddCreditsNewPaymentMethodFields({ clientSecret, isLoading, dependencies: d = DEPENDENCIES }, ref) { + const { stripeService } = d.useServices(); + const { resolvedTheme } = d.useTheme(); + + const stripePromise = useMemo(() => stripeService.getStripe(), [stripeService]); + const isDarkMode = useMemo(() => resolvedTheme === "dark", [resolvedTheme]); + const stripeAppearance = useMemo(() => buildStripeAppearance(isDarkMode), [isDarkMode]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!clientSecret) { + return null; + } + + return ( + Failed to load payment form
}> + {stripePromise ? ( + + + + ) : ( +
+ Payment processing is not available at this time. Please try again later or contact support if the issue persists. +
+ )} + + ); + } +); + +interface StripePaymentMethodFieldsProps { + dependencies: typeof DEPENDENCIES; +} + +const StripePaymentMethodFields = forwardRef(function StripePaymentMethodFields( + { dependencies: d }, + ref +) { + const stripe = d.useStripe(); + const elements = d.useElements(); + const [organization, setOrganization] = useState(""); + const [error, setError] = useState(null); + + useImperativeHandle( + ref, + () => ({ + /** + * Confirms the SetupIntent with Stripe and returns the resulting payment + * method id plus the typed organization. On failure the error is rendered + * inline and null is returned so the orchestrator can short-circuit. + */ + async addPaymentMethod() { + if (!stripe || !elements) { + return null; + } + + setError(null); + const setupResult = await stripe.confirmSetup({ elements, redirect: "if_required" }); + + if (setupResult.error) { + setError(setupResult.error.message || "Couldn't save your card. Please try again."); + return null; + } + + const paymentMethodId = setupResult.setupIntent?.payment_method; + if (typeof paymentMethodId !== "string") { + setError("Couldn't save your card. Please try again."); + return null; + } + + return { paymentMethodId, organization: organization || undefined }; + } + }), + [stripe, elements, organization] + ); + + return ( +
+
+

BILLING ADDRESS

+ +
+ +
+

CHOOSE A PAYMENT METHOD

+ +
+ + + + Organization (optional) + + setOrganization(e.target.value)} + className="shadow-sm" + /> + + + + + + Akash (operated by Overclock Labs) only charges your card if you choose to purchase credits after your trial. + + + + {error && ( + + {error} + + )} +
+ ); +}); + +/** + * Builds the Stripe Elements appearance config so it stays out of the JSX and + * recomputes only when the resolved theme flips between light and dark. + */ +function buildStripeAppearance(isDarkMode: boolean) { + return { + theme: (isDarkMode ? "night" : "stripe") as "night" | "stripe", + variables: { + colorPrimary: isDarkMode ? "#fff" : "#000", + colorSuccess: "#16a34a", + borderRadius: "8px", + fontWeightNormal: "500", + colorBackground: isDarkMode ? "#262626" : "#fff" + }, + rules: { + ".Input": { padding: "8px", boxShadow: "none" }, + ".Tab": { boxShadow: "none" }, + ".PickerItem": { boxShadow: "none" }, + ...(isDarkMode && { + ".Tab--selected": { border: "1px solid #fff", backgroundColor: "#262626" }, + ".TabIcon--selected": { fill: "#fff" }, + ".TabLabel--selected": { color: "#fff" } + }) + } + }; +} diff --git a/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.spec.tsx b/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.spec.tsx index 2186e923b8..1682fbf7be 100644 --- a/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.spec.tsx +++ b/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.spec.tsx @@ -11,6 +11,7 @@ import { ComponentMock, MockComponents } from "@tests/unit/mocks"; type PickerCardProps = Parameters[0]; type ContainerProps = Parameters[0]; +type SheetProps = Parameters[0]; describe(OnboardingPickerPage.name, () => { it("renders a card per template with its template name and matching sdl", () => { @@ -62,7 +63,12 @@ describe(OnboardingPickerPage.name, () => { }); it("renders an error alert when trial start fails terminally", () => { - const useEnsureTrialStarted: () => EnsureTrialStartedResult = () => ({ isWalletReady: false, isLoading: false, error: new Error("boom") }); + const useEnsureTrialStarted: () => EnsureTrialStartedResult = () => ({ + isWalletReady: false, + isLoading: false, + error: new Error("boom"), + refreshWallet: vi.fn() + }); setup({ dependencies: { useEnsureTrialStarted } }); expect(screen.getByText(/We couldn't set up your trial/i)).toBeInTheDocument(); @@ -94,24 +100,119 @@ describe(OnboardingPickerPage.name, () => { expect(replace).toHaveBeenCalledWith("/deployments/12345"); }); + it("labels the LLM card with the unlock CTA when the user is still trialing", () => { + const DeploymentTemplatePickerCard = vi.fn(ComponentMock); + setup({ isTrialing: true, dependencies: { DeploymentTemplatePickerCard } }); + + expect(getLlmCard(DeploymentTemplatePickerCard).ctaLabel).toBe("Unlock full trial to deploy"); + expect(getLlmCard(DeploymentTemplatePickerCard).ctaIcon).toBe("lock"); + }); + + it("labels the LLM card with the deploy CTA once the user is no longer trialing", () => { + const DeploymentTemplatePickerCard = vi.fn(ComponentMock); + setup({ isTrialing: false, dependencies: { DeploymentTemplatePickerCard } }); + + expect(getLlmCard(DeploymentTemplatePickerCard).ctaLabel).toBe("Deploy now"); + expect(getLlmCard(DeploymentTemplatePickerCard).ctaIcon).toBe("arrow"); + }); + + it("keeps the gated LLM card enabled while the wallet is still being prepared", () => { + const DeploymentTemplatePickerCard = vi.fn(ComponentMock); + setup({ + isTrialing: true, + dependencies: { + DeploymentTemplatePickerCard, + useEnsureTrialStarted: () => ({ isWalletReady: false, isLoading: true, error: null, refreshWallet: vi.fn() }) + } + }); + + const card = getLlmCard(DeploymentTemplatePickerCard); + expect(card.disabled).toBeFalsy(); + expect(card.ctaLabel).toBe("Unlock full trial to deploy"); + }); + + it("opens the verification sheet when the LLM CTA is clicked while trialing", () => { + const DeploymentTemplatePickerCard = vi.fn(ComponentMock); + const AddCreditsSheet = vi.fn(ComponentMock); + setup({ isTrialing: true, dependencies: { DeploymentTemplatePickerCard, AddCreditsSheet } }); + + act(() => getLlmCard(DeploymentTemplatePickerCard).onDeploy!()); + + const sheetProps = AddCreditsSheet.mock.calls.at(-1)![0] as SheetProps; + expect(sheetProps.open).toBe(true); + }); + + it("starts deploying the LLM template directly when the user is no longer trialing", () => { + const DeploymentTemplatePickerCard = vi.fn(ComponentMock); + const PhasedDeploymentContainer = vi.fn(ComponentMock); + setup({ + isTrialing: false, + templates: { helloWorld: "hello-sdl", imageGen: "image-sdl", llmChatbot: "llm-sdl" }, + dependencies: { DeploymentTemplatePickerCard, PhasedDeploymentContainer } + }); + + act(() => getLlmCard(DeploymentTemplatePickerCard).onDeploy!()); + + const containerProps = PhasedDeploymentContainer.mock.calls.at(-1)![0] as ContainerProps; + expect(containerProps.templateName).toBe("LLM Chatbot"); + expect(containerProps.sdl).toBe("llm-sdl"); + }); + + it("forwards isWalletReady to the verification sheet", () => { + const AddCreditsSheet = vi.fn(ComponentMock); + setup({ + isTrialing: true, + dependencies: { + AddCreditsSheet, + useEnsureTrialStarted: () => ({ isWalletReady: false, isLoading: true, error: null, refreshWallet: vi.fn() }) + } + }); + + const sheetProps = AddCreditsSheet.mock.calls.at(-1)![0] as SheetProps; + expect(sheetProps.isWalletReady).toBe(false); + }); + + it("closes the verification sheet when onDone fires", () => { + const DeploymentTemplatePickerCard = vi.fn(ComponentMock); + const AddCreditsSheet = vi.fn(ComponentMock); + setup({ isTrialing: true, dependencies: { DeploymentTemplatePickerCard, AddCreditsSheet } }); + + act(() => getLlmCard(DeploymentTemplatePickerCard).onDeploy!()); + expect((AddCreditsSheet.mock.calls.at(-1)![0] as SheetProps).open).toBe(true); + + act(() => (AddCreditsSheet.mock.calls.at(-1)![0] as SheetProps).onDone(100, "Acme")); + + expect((AddCreditsSheet.mock.calls.at(-1)![0] as SheetProps).open).toBe(false); + }); + + function getLlmCard(DeploymentTemplatePickerCard: ReturnType) { + return DeploymentTemplatePickerCard.mock.calls.find(call => (call[0] as PickerCardProps).title === "LLM Chatbot")![0] as PickerCardProps; + } + function setup( input: { templates?: OnboardingPickerPageProps["templates"]; enqueueSnackbar?: EnqueueSnackbar; replace?: () => void; + isTrialing?: boolean; + isWalletLoading?: boolean; dependencies?: Partial; } = {} ) { const enqueueSnackbar: EnqueueSnackbar = input.enqueueSnackbar ?? vi.fn(); const replace: () => void = input.replace ?? vi.fn(); + const isTrialing = input.isTrialing ?? true; + const isWalletLoading = input.isWalletLoading ?? false; + const useRouter: () => AppRouterInstance = vi.fn(() => mock({ replace })); const useSnackbar: () => ProviderContext = vi.fn(() => mock({ enqueueSnackbar })); - const useEnsureTrialStarted: () => EnsureTrialStartedResult = vi.fn(() => ({ isWalletReady: true, isLoading: false, error: null })); + const useEnsureTrialStarted: () => EnsureTrialStartedResult = vi.fn(() => ({ isWalletReady: true, isLoading: false, error: null, refreshWallet: vi.fn() })); + const useWallet: typeof DEPENDENCIES.useWallet = () => mock>({ isTrialing, isWalletLoading }); return render( ); } diff --git a/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.tsx b/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.tsx index d781d6fa90..7e33818b8b 100644 --- a/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.tsx +++ b/apps/deploy-web/src/components/onboarding-picker/OnboardingPickerPage.tsx @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { useState } from "react"; import { Alert, AlertDescription, Button, Snackbar } from "@akashnetwork/ui/components"; import { ArrowRight } from "iconoir-react"; @@ -7,18 +8,22 @@ import Head from "next/head"; import { useRouter } from "next/navigation"; import { useSnackbar } from "notistack"; +import { AddCreditsSheet } from "@src/components/auth/AddCreditsSheet/AddCreditsSheet"; import { DeploymentTemplatePickerCard } from "@src/components/deployments/DeploymentTemplatePickerCard/DeploymentTemplatePickerCard"; import { PhasedDeploymentContainer } from "@src/components/deployments/PhasedDeploymentContainer/PhasedDeploymentContainer"; import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo"; +import { useWallet } from "@src/context/WalletProvider"; import { useEnsureTrialStarted } from "@src/hooks/useEnsureTrialStarted"; import { UrlService } from "@src/utils/urlUtils"; export const DEPENDENCIES = { useRouter, useSnackbar, + useWallet, useEnsureTrialStarted, DeploymentTemplatePickerCard, - PhasedDeploymentContainer + PhasedDeploymentContainer, + AddCreditsSheet }; type DeployingState = { @@ -38,8 +43,12 @@ type OnboardingPickerPageProps = { export function OnboardingPickerPage({ templates, dependencies: d = DEPENDENCIES }: OnboardingPickerPageProps) { const { enqueueSnackbar } = d.useSnackbar(); const router = d.useRouter(); + const { isTrialing } = d.useWallet(); + const [isAddCreditsSheetOpen, setIsAddCreditsSheetOpen] = useState(false); const [deploying, setDeploying] = useState(null); const { isWalletReady, error: trialError } = d.useEnsureTrialStarted(); + const isLlmGated = isTrialing || !isWalletReady; + const isLlmAvailable = !isLlmGated; return ( <> @@ -90,6 +99,7 @@ export function OnboardingPickerPage({ templates, dependencies: d = DEPENDENCIES
setDeploying({ templateName: "Hello world", sdl: templates.helloWorld })} /> @@ -122,12 +131,12 @@ export function OnboardingPickerPage({ templates, dependencies: d = DEPENDENCIES description="Your own private AI chat. Secure, persistent, and fully under your control." priceBold="~$1.50/hr" priceRest=" · 1x RTX 4090 · 16 GB" - ctaLabel="Unlock full trial to deploy" + ctaLabel={isLlmGated ? "Unlock full trial to deploy" : "Deploy now"} + ctaIcon={isLlmGated ? "lock" : "arrow"} ctaVariant="outline" - ctaIcon="lock" heroImageSrc="/images/onboarding/llm-chatbot.png" heroImageAlt="LLM chatbot template" - disabled + onDeploy={() => (isLlmAvailable ? setDeploying({ templateName: "LLM Chatbot", sdl: templates.llmChatbot }) : setIsAddCreditsSheetOpen(true))} />
@@ -145,6 +154,13 @@ export function OnboardingPickerPage({ templates, dependencies: d = DEPENDENCIES
)} + + setIsAddCreditsSheetOpen(false)} + /> ); diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts index 28f760a4b7..e66e460d39 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts @@ -10,6 +10,7 @@ export type EnsureTrialStartedResult = { isWalletReady: boolean; isLoading: boolean; error: ReturnType["createError"]; + refreshWallet: ReturnType["refetch"]; }; /** @@ -22,8 +23,8 @@ export type EnsureTrialStartedResult = { * * Use only on pages that are part of the onboarding redesign — the call is unguarded. */ -export const useEnsureTrialStarted = (dependencies: typeof DEPENDENCIES = DEPENDENCIES): EnsureTrialStartedResult => { - const { wallet, create, isLoading, createError } = dependencies.useManagedWallet(); +export const useEnsureTrialStarted = (d: typeof DEPENDENCIES = DEPENDENCIES): EnsureTrialStartedResult => { + const { wallet, create, isLoading, createError, refetch } = d.useManagedWallet(); const isWalletReady = !!wallet?.address; useEffect(() => { @@ -33,5 +34,5 @@ export const useEnsureTrialStarted = (dependencies: typeof DEPENDENCIES = DEPEND create(); }, [isWalletReady, isLoading, createError, create]); - return { isWalletReady, isLoading, error: createError }; + return { isWalletReady, isLoading, error: createError, refreshWallet: refetch }; }; diff --git a/apps/deploy-web/tests/ui/onboarding-picker-unlock-trial.spec.ts b/apps/deploy-web/tests/ui/onboarding-picker-unlock-trial.spec.ts new file mode 100644 index 0000000000..855055daf8 --- /dev/null +++ b/apps/deploy-web/tests/ui/onboarding-picker-unlock-trial.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "./fixture/base-test"; +import { AddCreditsSheetPage } from "./pages/AddCreditsSheetPage"; +import { OnboardingPickerPage } from "./pages/OnboardingPickerPage"; + +test.describe("Onboarding picker — unlocking the gated LLM template via Add Credits", () => { + test.use({ userType: "new", authType: "passwordless" }); + + test("a fresh trialing user unlocks the LLM template by purchasing credits", async ({ page }) => { + test.setTimeout(5 * 60 * 1000); + + const onboardingPickerPage = new OnboardingPickerPage(page); + const addCreditsSheet = new AddCreditsSheetPage(page); + + await test.step("the gated LLM template CTA reads 'Unlock full trial to deploy' once the trial is active", async () => { + await expect(onboardingPickerPage.getLlmChatbotCta()).toHaveText(/unlock full trial to deploy/i, { timeout: 60_000 }); + }); + + await test.step("clicking the gated CTA opens the Add Credits sheet", async () => { + await onboardingPickerPage.clickLlmChatbotCta(); + await addCreditsSheet.waitForOpen(); + }); + + await test.step("submit a $100 credit purchase with a test card", async () => { + await addCreditsSheet.pickPredefinedAmount("100"); + await addCreditsSheet.fillStripeAddress({ + name: "E2E Test User", + line1: "123 Test Street", + city: "San Francisco", + state: "CA", + zip: "94105" + }); + await addCreditsSheet.fillStripeCard({ number: "4242424242424242", expiry: "12/30", cvc: "123" }); + await addCreditsSheet.submit(); + }); + + await test.step("the Add Credits sheet stays open while the payment is being processed", async () => { + await expect(page.getByText("Processing payment...")).toBeVisible({ timeout: 30_000 }); + await expect(addCreditsSheet.getDialog()).toBeVisible(); + }); + + await test.step("snackbars appear in order: processing → successful → welcome", async () => { + await expect(page.getByText("Processing payment...")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Payment successful!")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Welcome to Akash!")).toBeVisible({ timeout: 30_000 }); + }); + + await test.step("the Add Credits sheet closes once the trial flips", async () => { + await expect(addCreditsSheet.getDialog()).toBeHidden({ timeout: 30_000 }); + }); + + await test.step("the LLM template CTA unlocks to 'Deploy now'", async () => { + await expect(onboardingPickerPage.getLlmChatbotCta()).toHaveText(/deploy now/i, { timeout: 30_000 }); + await expect(onboardingPickerPage.getLlmChatbotCta()).toBeEnabled(); + }); + }); +}); diff --git a/apps/deploy-web/tests/ui/pages/AddCreditsSheetPage.ts b/apps/deploy-web/tests/ui/pages/AddCreditsSheetPage.ts new file mode 100644 index 0000000000..c29e754689 --- /dev/null +++ b/apps/deploy-web/tests/ui/pages/AddCreditsSheetPage.ts @@ -0,0 +1,82 @@ +import type { FrameLocator, Locator, Page } from "@playwright/test"; + +export class AddCreditsSheetPage { + constructor(readonly page: Page) {} + + getDialog(): Locator { + return this.page.getByRole("dialog").filter({ has: this.page.getByText("Add credits", { exact: true }) }); + } + + async waitForOpen() { + await this.getDialog().waitFor({ state: "visible", timeout: 15_000 }); + } + + async pickPredefinedAmount(amount: "50" | "100" | "500") { + await this.getDialog().getByRole("radio", { name: amount, exact: true }).click(); + } + + async fillStripeAddress(input: { name: string; line1: string; city: string; state: string; zip: string }) { + const addressFrame = this.getAddressFrame(); + + const nameInput = addressFrame.getByLabel("Full name"); + await nameInput.click(); + await nameInput.pressSequentially(input.name); + + await addressFrame.getByLabel("Country or region").selectOption("United States"); + + const addressInput = addressFrame.getByLabel("Address").first(); + await addressInput.click(); + await addressInput.pressSequentially(input.line1); + await addressInput.press("Escape"); + + const zipInput = addressFrame.getByLabel(/zip/i); + await zipInput.click(); + await zipInput.pressSequentially(input.zip); + + const cityInput = addressFrame.getByLabel("City"); + await cityInput.click(); + await cityInput.pressSequentially(input.city); + + await addressFrame.getByLabel("State").selectOption(input.state); + } + + async fillStripeCard(input: { number: string; expiry: string; cvc: string }) { + const cardFrame = this.getCardFrame(); + + const cardNumber = cardFrame.getByLabel(/card number/i); + await cardNumber.click(); + await cardNumber.pressSequentially(input.number); + + const expiry = cardFrame.getByLabel(/expiration/i); + await expiry.click(); + await expiry.pressSequentially(input.expiry); + + const cvc = cardFrame.getByLabel(/security/i); + await cvc.click(); + await cvc.pressSequentially(input.cvc); + } + + async submit() { + const submitButton = this.getDialog().getByRole("button", { name: /purchase credits/i }); + await submitButton.scrollIntoViewIfNeeded(); + await submitButton.dispatchEvent("click"); + } + + private getAddressFrame(): FrameLocator { + return this.page + .getByRole("heading", { name: /billing address/i }) + .locator("..") + .locator("iframe") + .first() + .contentFrame(); + } + + private getCardFrame(): FrameLocator { + return this.page + .getByRole("heading", { name: /choose a payment method/i }) + .locator("..") + .locator("iframe") + .first() + .contentFrame(); + } +} diff --git a/apps/deploy-web/tests/ui/pages/OnboardingPickerPage.ts b/apps/deploy-web/tests/ui/pages/OnboardingPickerPage.ts index 635bad18f0..a2fbcc807b 100644 --- a/apps/deploy-web/tests/ui/pages/OnboardingPickerPage.ts +++ b/apps/deploy-web/tests/ui/pages/OnboardingPickerPage.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { testEnvConfig } from "../fixture/test-env.config"; @@ -23,4 +23,21 @@ export class OnboardingPickerPage { await card.getByRole("button", { name: /deploy now/i }).click(); } + + getLlmChatbotCard(): Locator { + const heading = this.page.getByRole("heading", { name: "LLM Chatbot", exact: true }); + return this.page + .locator("div") + .filter({ has: heading }) + .filter({ has: this.page.getByRole("button") }) + .last(); + } + + getLlmChatbotCta(): Locator { + return this.getLlmChatbotCard().getByRole("button"); + } + + async clickLlmChatbotCta() { + await this.getLlmChatbotCta().click(); + } } diff --git a/packages/ui/components/field.tsx b/packages/ui/components/field.tsx new file mode 100644 index 0000000000..ab5b775c0b --- /dev/null +++ b/packages/ui/components/field.tsx @@ -0,0 +1,169 @@ +"use client"; +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "../utils"; +import { Separator } from "./separator"; + +const FieldSet = React.forwardRef>(({ className, ...props }, ref) => ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className)} + {...props} + /> +)); +FieldSet.displayName = "FieldSet"; + +const fieldLegendVariants = cva("mb-3 font-medium", { + variants: { + variant: { + legend: "text-base", + label: "text-sm" + } + }, + defaultVariants: { + variant: "legend" + } +}); + +const FieldLegend = React.forwardRef & VariantProps>( + ({ className, variant = "legend", ...props }, ref) => ( + + ) +); +FieldLegend.displayName = "FieldLegend"; + +const FieldGroup = React.forwardRef>(({ className, ...props }, ref) => ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> +)); +FieldGroup.displayName = "FieldGroup"; + +const fieldVariants = cva("group/field data-[invalid=true]:text-destructive flex w-full gap-3", { + variants: { + orientation: { + vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto", + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],&>[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start" + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],&>[role=radio]]:mt-px" + ] + } + }, + defaultVariants: { + orientation: "vertical" + } +}); + +const Field = React.forwardRef & VariantProps>( + ({ className, orientation = "vertical", ...props }, ref) => ( +
+ ) +); +Field.displayName = "Field"; + +const FieldContent = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +FieldContent.displayName = "FieldContent"; + +const FieldLabel = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + [data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", + "peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + "group-data-[disabled=true]/field:opacity-50", + className + )} + {...props} + /> + ) +); +FieldLabel.displayName = "FieldLabel"; + +const FieldTitle = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +FieldTitle.displayName = "FieldTitle"; + +const FieldDescription = React.forwardRef>(({ className, ...props }, ref) => ( +

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> +)); +FieldDescription.displayName = "FieldDescription"; + +const FieldSeparator = React.forwardRef & { children?: React.ReactNode }>( + ({ className, children, ...props }, ref) => ( +

+ + {children && ( + + {children} + + )} +
+ ) +); +FieldSeparator.displayName = "FieldSeparator"; + +const FieldError = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<"div"> & { + errors?: Array<{ message?: string } | undefined>; + } +>(({ className, children, errors, ...props }, ref) => { + const content = React.useMemo(() => { + if (children) return children; + if (!errors?.length) return null; + + const filtered = errors.filter((e): e is { message?: string } => Boolean(e?.message)); + if (filtered.length === 0) return null; + if (filtered.length === 1) return filtered[0].message; + + return ( +
    + {filtered.map((error, index) => ( +
  • {error.message}
  • + ))} +
+ ); + }, [children, errors]); + + if (!content) return null; + + return ( +
+ {content} +
+ ); +}); +FieldError.displayName = "FieldError"; + +export { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle }; diff --git a/packages/ui/components/index.tsx b/packages/ui/components/index.tsx index cbe7b821f3..7701747b5c 100644 --- a/packages/ui/components/index.tsx +++ b/packages/ui/components/index.tsx @@ -12,6 +12,7 @@ export * from "./dialog"; export * from "./multiple-selector"; export * from "./drawer"; export * from "./dropdown-menu"; +export * from "./field"; export * from "./form"; export * from "./label"; export * from "./input"; @@ -25,6 +26,7 @@ export * from "./progress"; export * from "./radio-group"; export * from "./scroll-area"; export * from "./separator"; +export * from "./sheet"; export * from "./skeleton"; export * from "./slider"; export * from "./switch"; diff --git a/packages/ui/components/sheet.tsx b/packages/ui/components/sheet.tsx new file mode 100644 index 0000000000..0680d6c29c --- /dev/null +++ b/packages/ui/components/sheet.tsx @@ -0,0 +1,92 @@ +"use client"; +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "../utils"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ) +); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "bg-popover data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[751] gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b", + bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t", + left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm" + } + }, + defaultVariants: { + side: "right" + } + } +); + +interface SheetContentProps extends React.ComponentPropsWithoutRef, VariantProps { + hideCloseButton?: boolean; +} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = "right", className, children, hideCloseButton, ...props }, ref) => ( + + + + {children} + + {!hideCloseButton && ( + + + Close + + )} + + + ) +); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => +); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger };