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 ( - !v && onClose()}> + !v && onClose()}> diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx index 089e850..dfab427 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from "react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" -import { Input } from "@workspace/ui/components/input" import { Button } from "@workspace/ui/components/button" import { Slider } from "@workspace/ui/components/slider" import { Separator } from "@workspace/ui/components/separator" import { Badge } from "@workspace/ui/components/badge" import { useTradeState } from "../../hooks/useTradeState" +import { NumberInput } from "@/shared/components/NumberInput" import { useTokenPrices } from "../../hooks/useTokenPrices" import { useTradeFees } from "../../hooks/useTradeFees" import { useTokenBalances } from "../../../wallet/hooks/useTokenBalances" @@ -275,21 +275,17 @@ function TradeInputs({ trade, validationError }: { trade: ReturnType )}
-
- 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