diff --git a/apps/web/src/features/earn/components/stake/StakeDialog.test.tsx b/apps/web/src/features/earn/components/stake/StakeDialog.test.tsx new file mode 100644 index 0000000..a582bb5 --- /dev/null +++ b/apps/web/src/features/earn/components/stake/StakeDialog.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { StakeDialog } from "./StakeDialog" + +// Mock the mutation hook +const mockMutateAsync = vi.fn().mockResolvedValue("0x123") +vi.mock("../../hooks/useStakeMutation", () => ({ + useStakeMutation: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe("StakeDialog", () => { + it("renders correctly and switches tabs", async () => { + const user = userEvent.setup() + render( {}} />) + + // Check defaults (stake mode) + expect(screen.getByRole("heading", { name: "Stake SO4" })).toBeInTheDocument() + expect(screen.getByText("Duration Multiplier")).toBeInTheDocument() + + // Switch to unstake + await user.click(screen.getByRole("button", { name: "Unstake" })) + expect(screen.getByRole("heading", { name: "Unstake SO4" })).toBeInTheDocument() + expect(screen.queryByText("Duration Multiplier")).not.toBeInTheDocument() + }) + + it("handles input and submit correctly", async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + render() + + const input = screen.getByPlaceholderText("0.00") + await user.type(input, "100") + + const submitBtn = screen.getByRole("button", { name: "Stake SO4", exact: true }) + expect(submitBtn).not.toBeDisabled() + + await user.click(submitBtn) + + // The mutation is mocked so it will resolve and close the dialog + expect(mockMutateAsync).toHaveBeenCalledWith({ + action: "stake", + amount: 100, + durationMultiplier: 1, // Default multiplier + }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it("changes the duration multiplier when a button is clicked", async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + render() + + const input = screen.getByPlaceholderText("0.00") + await user.type(input, "50") + + // Click the 2x multiplier + await user.click(screen.getByRole("button", { name: "2x" })) + + const submitBtn = screen.getByRole("button", { name: "Stake SO4", exact: true }) + await user.click(submitBtn) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + action: "stake", + amount: 50, + durationMultiplier: 2, + }) + }) +}) diff --git a/apps/web/src/features/earn/components/stake/StakeDialog.tsx b/apps/web/src/features/earn/components/stake/StakeDialog.tsx new file mode 100644 index 0000000..f356928 --- /dev/null +++ b/apps/web/src/features/earn/components/stake/StakeDialog.tsx @@ -0,0 +1,114 @@ +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { Button } from "@workspace/ui/components/button" +import { Input } from "@workspace/ui/components/input" +import { useStakeMutation } from "../../hooks/useStakeMutation" + +export type StakeDialogProps = { + isOpen: boolean + onOpenChange: (open: boolean) => void + initialAction?: "stake" | "unstake" +} + +export function StakeDialog({ + isOpen, + onOpenChange, + initialAction = "stake", +}: StakeDialogProps) { + const [action, setAction] = useState<"stake" | "unstake">(initialAction) + const [amount, setAmount] = useState("") + const [multiplier, setMultiplier] = useState(1) + const mutation = useStakeMutation() + + const handleSubmit = async () => { + const parsedAmount = parseFloat(amount) + if (isNaN(parsedAmount) || parsedAmount <= 0) return + + try { + await mutation.mutateAsync({ + action, + amount: parsedAmount, + durationMultiplier: multiplier, + }) + onOpenChange(false) + setAmount("") + setMultiplier(1) + } catch (err) { + // errors handled by submitTx or mutation onError + } + } + + return ( + + + + {action === "stake" ? "Stake SO4" : "Unstake SO4"} + + +
+ + +
+ +
+
+ + setAmount(e.target.value)} + /> +
+ + {action === "stake" && ( +
+ +
+ {[1, 1.5, 2].map((m) => ( + + ))} +
+
+ )} + + +
+
+
+ ) +} diff --git a/apps/web/src/features/earn/hooks/useStakeMutation.ts b/apps/web/src/features/earn/hooks/useStakeMutation.ts new file mode 100644 index 0000000..91019d5 --- /dev/null +++ b/apps/web/src/features/earn/hooks/useStakeMutation.ts @@ -0,0 +1,27 @@ +import { useMutation } from "@tanstack/react-query" +import { useWalletStore } from "@/features/wallet/store/wallet-store" +import { stakeSO4, unstakeSO4 } from "../../lib/earn" + +export function useStakeMutation() { + const { address } = useWalletStore() + + return useMutation({ + mutationFn: async ({ + action, + amount, + durationMultiplier, + }: { + action: "stake" | "unstake" + amount: number + durationMultiplier?: number + }) => { + if (!address) throw new Error("Wallet not connected") + + if (action === "stake") { + return stakeSO4(address, amount) // Note: smart contract currently only accepts account & amount + } else { + return unstakeSO4(address, amount) + } + }, + }) +}