diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c01fa8..bd00809 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,5 +42,8 @@ jobs:
- name: Typecheck
run: bun typecheck
+ - name: Test
+ run: cd apps/web && bun run test
+
- name: Build
run: bun run build
diff --git a/apps/web/package.json b/apps/web/package.json
index 1d9bf04..5cb694c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,6 +7,9 @@
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit",
@@ -52,10 +55,16 @@
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@vitejs/plugin-react": "^5.1.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^14.0.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@vitest/coverage-v8": "^4.1.8",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.1.1",
+ "jsdom": "^22.1.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.60.0",
- "vite": "^7.3.2"
+ "vite": "^7.3.2",
+ "vitest": "^4.1.8"
}
}
diff --git a/apps/web/src/features/trade/components/TradePage.tsx b/apps/web/src/features/trade/components/TradePage.tsx
index e9b0d28..3afdac2 100644
--- a/apps/web/src/features/trade/components/TradePage.tsx
+++ b/apps/web/src/features/trade/components/TradePage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"
import { getRouteApi } from "@tanstack/react-router"
import { useTradeState } from "../hooks/useTradeState"
import { useOrderEventPolling } from "../hooks/useOrderEventPolling"
+import { saveReferralCode } from "@/lib/soroban/referral-code"
import { Navbar } from "../../../ui/Navbar"
import { TVChart } from "./chart/TVChart"
import { TradePanel } from "./trade-panel/TradePanel"
@@ -27,11 +28,18 @@ export function TradePage() {
if (search.type) setTradeType(search.type === "long" ? "Long" : "Short")
}, [search.market, search.type, setToTokenAddress, setTradeType])
+ useEffect(() => {
+ if (!search.ref) return
+ const normalized = search.ref.toUpperCase().trim()
+ if (!normalized) return
+ saveReferralCode(normalized)
+ }, [search.ref])
+
return (
-
+
{/* ── Left: Chart + Bottom Tabs ──────────────────────────────── */}
{/* Chart takes the majority of height */}
@@ -40,7 +48,7 @@ export function TradePage() {
{/* Bottom tabs: Positions / Orders / Trades / Claims */}
-
+
trade.setActivePosition({
@@ -55,7 +63,7 @@ export function TradePage() {
{/* ── Right: Trade Panel ─────────────────────────────────────── */}
-
diff --git a/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.tsx b/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.tsx
index 28d58a4..ca02f19 100644
--- a/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.tsx
+++ b/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.tsx
@@ -8,6 +8,7 @@ import {
validateReferralCode,
} from "@/features/referrals/lib/referrals"
import { getTraderReferralCode } from "@/lib/soroban/referral-storage"
+import { readStoredReferralCode } from "@/lib/soroban/referral-code"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { queryKeys } from "@/shared/lib/query-keys"
@@ -17,7 +18,7 @@ type Props = {
export function ApplyReferralCodePrompt({ account }: Props) {
const queryClient = useQueryClient()
- const [code, setCode] = useState("")
+ const [code, setCode] = useState(() => readStoredReferralCode() ?? "")
const [error, setError] = useState
(null)
const [pending, setPending] = useState(false)
const [dismissed, setDismissed] = useState(false)
diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
index 6445007..26d4512 100644
--- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
+++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
@@ -8,6 +8,9 @@ import {
} from "@workspace/ui/components/dialog"
import { Button } from "@workspace/ui/components/button"
import { createSwapOrder, sendBatchOrderTxn, type DecreaseOrderParams, type IncreaseOrderParams } from "../../lib/stellar"
+import { applyReferralCode } from "@/features/referrals/lib/referrals"
+import { readStoredReferralCode } from "@/lib/soroban/referral-code"
+import { getTraderReferralCode } from "@/lib/soroban/referral-storage"
import { formatUsd } from "../../lib/trade-math"
import type { useTradeState } from "../../hooks/useTradeState"
import { useWalletStore } from "@/features/wallet/store/wallet-store"
@@ -21,7 +24,7 @@ import { checkAllowance, buildApproveTransaction } from "@/lib/contracts/sac-tok
import { prepareAndSign } from "@/lib/soroban/tx-builder"
import { submitTx } from "@/shared/hooks/useTxSubmit"
import { walletKit } from "@/features/wallet/lib/wallet-kit"
-import { NETWORK, explorerTxUrl } from "@/app/config/network"
+import { NETWORK } from "@/app/config/network"
import { CONTRACTS } from "@/app/config/contracts"
import { fetchFeeConfig } from "../../lib/data-store"
import { useQuery } from "@tanstack/react-query"
@@ -45,7 +48,7 @@ export function ConfirmationDialog({ open, onClose, tradeState, sizeUsd, entryPr
const [estimatingFee, setEstimatingFee] = useState(false)
const [allowanceState, setAllowanceState] = useState<"checking" | "sufficient" | "insufficient" | "approving" | "approved">("checking")
const [approveError, setApproveError] = useState(null)
- const account = useWalletStore((state) => state.address)
+ const account = useWalletStore((state: { address: string | null }) => state.address)
const { tradeFlags, toTokenAddress, collateralAddress, leverage, fromAmount, triggerPrice, sidecarOrders, clearSidecarOrders } =
tradeState
@@ -190,6 +193,18 @@ export function ConfirmationDialog({ open, onClose, tradeState, sizeUsd, entryPr
throw new Error("Connect your wallet before placing an order.")
}
+ const storedReferralCode = readStoredReferralCode()
+ if (storedReferralCode) {
+ const existingCode = await getTraderReferralCode(account)
+ if (!existingCode) {
+ try {
+ await applyReferralCode(account, storedReferralCode)
+ } catch (error) {
+ console.warn("Referral code could not be auto-applied:", error)
+ }
+ }
+ }
+
if (allowanceState === "insufficient") {
await handleApprove()
}
@@ -222,7 +237,7 @@ export function ConfirmationDialog({ open, onClose, tradeState, sizeUsd, entryPr
const typeLabel = tradeFlags.isSwap ? "Swap" : tradeFlags.isLong ? "Long" : "Short"
return (
-
-
- setFromAmount(e.target.value)}
- className="pr-16 font-mono text-sm"
- />
-
- {fromTokenAddress}
-
-
- {fromUsd > 0 && (
-
{formatUsd(fromUsd)}
- )}
+
setFromAmount(walletBalance.toString()) : undefined}
+ usdValue={fromUsd > 0 ? fromUsd : undefined}
+ />
+
+ {fromTokenAddress}
+
{validationError && (
{validationError}
)}
diff --git a/apps/web/src/lib/soroban/referral-code.ts b/apps/web/src/lib/soroban/referral-code.ts
index 38552da..ebcf81e 100644
--- a/apps/web/src/lib/soroban/referral-code.ts
+++ b/apps/web/src/lib/soroban/referral-code.ts
@@ -33,11 +33,22 @@ export function scValToReferralCode(value: unknown): string | null {
export const AFFILIATE_CODE_STORAGE_KEY = "so4-affiliate-code"
export const REFERRAL_PROMPT_STORAGE_KEY = "so4-referral-prompt-done"
+export const REFERRAL_CODE_STORAGE_KEY = "so4-referral-code"
export function affiliateCodeStorageKey(account: string): string {
return `${AFFILIATE_CODE_STORAGE_KEY}:${account}`
}
+export function saveReferralCode(code: string): void {
+ if (typeof window === "undefined") return
+ localStorage.setItem(REFERRAL_CODE_STORAGE_KEY, code.toUpperCase().trim())
+}
+
+export function readStoredReferralCode(): string | null {
+ if (typeof window === "undefined") return null
+ return localStorage.getItem(REFERRAL_CODE_STORAGE_KEY)
+}
+
export function referralPromptStorageKey(account: string): string {
return `${REFERRAL_PROMPT_STORAGE_KEY}:${account}`
}
diff --git a/apps/web/src/routes/trade.tsx b/apps/web/src/routes/trade.tsx
index af963e2..925b8c7 100644
--- a/apps/web/src/routes/trade.tsx
+++ b/apps/web/src/routes/trade.tsx
@@ -5,6 +5,7 @@ import { TradePage } from "../features/trade/components/TradePage"
export type TradeSearch = {
market?: string
type?: "long" | "short"
+ ref?: string
}
export const Route = createFileRoute("/trade")({
@@ -12,5 +13,6 @@ export const Route = createFileRoute("/trade")({
validateSearch: (search: Record): TradeSearch => ({
market: typeof search.market === "string" ? search.market : undefined,
type: search.type === "long" || search.type === "short" ? search.type : undefined,
+ ref: typeof search.ref === "string" ? search.ref : undefined,
}),
})
diff --git a/apps/web/src/shared/components/NumberInput.test.tsx b/apps/web/src/shared/components/NumberInput.test.tsx
new file mode 100644
index 0000000..d1d5d8e
--- /dev/null
+++ b/apps/web/src/shared/components/NumberInput.test.tsx
@@ -0,0 +1,50 @@
+import "@testing-library/jest-dom"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { describe, expect, it, vi } from "vitest"
+import { useState } from "react"
+import { NumberInput } from "./NumberInput"
+
+type WrapperProps = {
+ defaultValue: string
+ usdValue?: number | null
+ onMax?: () => void
+}
+
+function Wrapper({ defaultValue, usdValue, onMax }: WrapperProps) {
+ const [value, setValue] = useState(defaultValue)
+ return (
+
+ )
+}
+
+describe("NumberInput", () => {
+ it("renders current value and USD equivalent", () => {
+ render()
+ expect(screen.getByRole("textbox")).toHaveValue("1.23")
+ expect(screen.getByText("$12.30")).toBeInTheDocument()
+ })
+
+ it("calls onValueChange for valid decimal input and ignores invalid characters", async () => {
+ render()
+
+ const input = screen.getByRole("textbox")
+ await userEvent.type(input, "12.34")
+
+ expect(input).toHaveValue("12.34")
+ await userEvent.type(input, "a")
+ expect(input).toHaveValue("12.34")
+ })
+
+ it("fires the max button when provided", async () => {
+ const onMax = vi.fn()
+ render()
+ await userEvent.click(screen.getByRole("button", { name: "MAX" }))
+ expect(onMax).toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/shared/components/NumberInput.tsx b/apps/web/src/shared/components/NumberInput.tsx
new file mode 100644
index 0000000..9bd938e
--- /dev/null
+++ b/apps/web/src/shared/components/NumberInput.tsx
@@ -0,0 +1,62 @@
+import { Button } from "@workspace/ui/components/button"
+import { Input } from "@workspace/ui/components/input"
+import { formatUsd } from "@/shared/lib/format"
+import type { ComponentPropsWithoutRef } from "react"
+
+type NumberInputProps = Omit, "onChange" | "type"> & {
+ value: string
+ onValueChange: (value: string) => void
+ usdValue?: number | null
+ onMax?: () => void
+ maxButtonLabel?: string
+}
+
+const DECIMAL_INPUT_REGEX = /^$|^[0-9]*\.?[0-9]*$/
+
+export function NumberInput({
+ value,
+ onValueChange,
+ usdValue,
+ onMax,
+ maxButtonLabel = "MAX",
+ placeholder,
+ className,
+ ...props
+}: NumberInputProps) {
+ function handleChange(event: React.ChangeEvent) {
+ const nextValue = event.target.value
+ if (!DECIMAL_INPUT_REGEX.test(nextValue)) return
+ onValueChange(nextValue)
+ }
+
+ return (
+
+
+
+ {onMax ? (
+
+ ) : null}
+
+ {typeof usdValue === "number" && !Number.isNaN(usdValue) ? (
+
{formatUsd(usdValue)}
+ ) : null}
+
+ )
+}
diff --git a/apps/web/src/shared/lib/bignum.test.ts b/apps/web/src/shared/lib/bignum.test.ts
index 0fa6ade..94525fe 100644
--- a/apps/web/src/shared/lib/bignum.test.ts
+++ b/apps/web/src/shared/lib/bignum.test.ts
@@ -1,5 +1,4 @@
-import assert from "node:assert/strict"
-import { describe, it } from "node:test"
+import { describe, expect, it } from "vitest"
import {
formatSorobanAmount,
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 402be25..65a805d 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -15,6 +15,29 @@ const config = defineConfig({
tanstackStart(),
viteReact(),
],
+ ssr: {
+ noExternal: ["react", "react-dom"],
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ deps: {
+ inline: [
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "react/jsx-dev-runtime",
+ "@testing-library/react",
+ "@testing-library/user-event",
+ "@testing-library/jest-dom",
+ ],
+ },
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ },
+ },
})
export default config
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..b61a7e4
Binary files /dev/null and b/bun.lockb differ