diff --git a/apps/web/src/features/earn/components/discover/opportunity-card.test.tsx b/apps/web/src/features/earn/components/discover/opportunity-card.test.tsx
new file mode 100644
index 0000000..5e4be6d
--- /dev/null
+++ b/apps/web/src/features/earn/components/discover/opportunity-card.test.tsx
@@ -0,0 +1,69 @@
+import { describe, it, expect, afterEach, vi } from "vitest"
+import { cleanup, render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { OpportunityCard } from "./opportunity-card"
+
+afterEach(cleanup)
+
+describe("OpportunityCard", () => {
+ it("renders APR, token pair, TVL, and action label for valid opportunities", () => {
+ const onAction = vi.fn()
+ render(
+
+ )
+
+ // Token Pair / Name
+ expect(screen.getByText("WBTC/USDC Liquidity")).toBeInTheDocument()
+
+ // Key metrics labels
+ expect(screen.getByText("APR")).toBeInTheDocument()
+ expect(screen.getByText("TVL")).toBeInTheDocument()
+
+ // Action button
+ const button = screen.getByRole("button", { name: "Deposit" })
+ expect(button).toBeInTheDocument()
+ expect(button).not.toBeDisabled()
+ })
+
+ it("correctly renders the unavailable/coming-soon state", () => {
+ render(
+
+ )
+
+ const button = screen.getByRole("button", { name: "Coming Soon" })
+ expect(button).toBeInTheDocument()
+ expect(button).toBeDisabled()
+ })
+
+ it("fires onAction when the action button is clicked", async () => {
+ const onAction = vi.fn()
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole("button", { name: "Earn" }))
+ expect(onAction).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/web/src/features/earn/components/discover/opportunity-card.tsx b/apps/web/src/features/earn/components/discover/opportunity-card.tsx
new file mode 100644
index 0000000..6eeabca
--- /dev/null
+++ b/apps/web/src/features/earn/components/discover/opportunity-card.tsx
@@ -0,0 +1,74 @@
+import { cn } from "@workspace/ui/lib/utils"
+import { Button } from "@workspace/ui/components/button"
+import { formatPct, formatUsd } from "@/shared/lib/format"
+import { TokenIcon } from "@/shared/components/TokenIcon"
+
+export type OpportunityCardProps = {
+ name: string
+ tokens: string[]
+ apy: number
+ tvlUsd: number
+ isAvailable?: boolean
+ onAction?: () => void
+ actionLabel?: string
+}
+
+export function OpportunityCard({
+ name,
+ tokens,
+ apy,
+ tvlUsd,
+ isAvailable = true,
+ onAction,
+ actionLabel = "Earn",
+}: OpportunityCardProps) {
+ return (
+
+
+
+
+ {tokens.map((symbol, i) => (
+
+ ))}
+
+
{name}
+
+
+
+
+
+
APR
+
+ {formatPct(apy, { sign: false })}
+
+
+
+
TVL
+
+ {formatUsd(tvlUsd, { compact: true })}
+
+
+
+
+
+
+ )
+}