Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/web/src/features/earn/components/stake/StakeDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StakeDialog isOpen={true} onOpenChange={() => {}} />)

// 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(<StakeDialog isOpen={true} onOpenChange={onOpenChange} />)

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(<StakeDialog isOpen={true} onOpenChange={onOpenChange} />)

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,
})
})
})
114 changes: 114 additions & 0 deletions apps/web/src/features/earn/components/stake/StakeDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{action === "stake" ? "Stake SO4" : "Unstake SO4"}</DialogTitle>
</DialogHeader>

<div className="mb-4 flex gap-2">
<Button
variant={action === "stake" ? "default" : "outline"}
onClick={() => setAction("stake")}
className="w-full"
>
Stake
</Button>
<Button
variant={action === "unstake" ? "default" : "outline"}
onClick={() => setAction("unstake")}
className="w-full"
>
Unstake
</Button>
</div>

<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">Amount</label>
<Input
type="number"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>

{action === "stake" && (
<div>
<label className="mb-2 block text-sm font-medium">Duration Multiplier</label>
<div className="flex gap-2">
{[1, 1.5, 2].map((m) => (
<Button
key={m}
variant={multiplier === m ? "default" : "outline"}
onClick={() => setMultiplier(m)}
className="flex-1"
>
{m}x
</Button>
))}
</div>
</div>
)}

<Button
className="w-full"
disabled={mutation.isPending || !amount || parseFloat(amount) <= 0}
onClick={() => void handleSubmit()}
>
{mutation.isPending
? "Confirming..."
: action === "stake"
? "Stake SO4"
: "Unstake SO4"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
27 changes: 27 additions & 0 deletions apps/web/src/features/earn/hooks/useStakeMutation.ts
Original file line number Diff line number Diff line change
@@ -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)
}
},
})
}