Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 10 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
14 changes: 11 additions & 3 deletions apps/web/src/features/trade/components/TradePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 (
<div className="flex h-svh flex-col overflow-hidden bg-background text-foreground">
<Navbar variant="app" />
<CircuitBreakerBanner symbol={trade.toTokenAddress} />
<div className="flex min-h-0 flex-1 lg:px-6">
<div className="flex min-h-0 flex-1 flex-col lg:flex-row lg:px-6">
{/* ── Left: Chart + Bottom Tabs ──────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Chart takes the majority of height */}
Expand All @@ -40,7 +48,7 @@ export function TradePage() {
</div>

{/* Bottom tabs: Positions / Orders / Trades / Claims */}
<div className="h-64 shrink-0 overflow-auto border-t border-border">
<div className="h-64 shrink-0 overflow-auto border-t border-border lg:border-t-0">
<BottomTabs
onSelectPosition={(pos) =>
trade.setActivePosition({
Expand All @@ -55,7 +63,7 @@ export function TradePage() {
</div>

{/* ── Right: Trade Panel ─────────────────────────────────────── */}
<div className="w-80 shrink-0 overflow-y-auto border-l border-border">
<div className="w-full shrink-0 overflow-y-auto border-t border-border lg:border-t-0 lg:border-l lg:w-80">
<TradePanel />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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<string | null>(null)
const [pending, setPending] = useState(false)
const [dismissed, setDismissed] = useState(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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<string | null>(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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -222,7 +237,7 @@ export function ConfirmationDialog({ open, onClose, tradeState, sizeUsd, entryPr
const typeLabel = tradeFlags.isSwap ? "Swap" : tradeFlags.isLong ? "Long" : "Short"

return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<Dialog open={open} onOpenChange={(v: boolean) => !v && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
Expand Down
28 changes: 12 additions & 16 deletions apps/web/src/features/trade/components/trade-panel/TradePanel.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -275,21 +275,17 @@ function TradeInputs({ trade, validationError }: { trade: ReturnType<typeof useT
</span>
)}
</div>
<div className="relative">
<Input
type="number"
placeholder="0.00"
value={fromAmount}
onChange={(e) => setFromAmount(e.target.value)}
className="pr-16 font-mono text-sm"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
{fromTokenAddress}
</span>
</div>
{fromUsd > 0 && (
<p className="text-right text-xs text-muted-foreground">{formatUsd(fromUsd)}</p>
)}
<NumberInput
value={fromAmount}
onValueChange={setFromAmount}
placeholder="0.00"
className="pr-16 font-mono text-sm"
onMax={walletBalance !== undefined ? () => setFromAmount(walletBalance.toString()) : undefined}
usdValue={fromUsd > 0 ? fromUsd : undefined}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
{fromTokenAddress}
</span>
{validationError && (
<p className="text-xs text-red-500">{validationError}</p>
)}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/lib/soroban/referral-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
2 changes: 2 additions & 0 deletions apps/web/src/routes/trade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { TradePage } from "../features/trade/components/TradePage"
export type TradeSearch = {
market?: string
type?: "long" | "short"
ref?: string
}

export const Route = createFileRoute("/trade")({
component: TradePage,
validateSearch: (search: Record<string, unknown>): 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,
}),
})
50 changes: 50 additions & 0 deletions apps/web/src/shared/components/NumberInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NumberInput
value={value}
onValueChange={setValue}
usdValue={usdValue}
onMax={onMax}
/>
)
}

describe("NumberInput", () => {
it("renders current value and USD equivalent", () => {
render(<NumberInput value="1.23" onValueChange={vi.fn()} usdValue={12.3} />)
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(<Wrapper defaultValue="" />)

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(<NumberInput value="" onValueChange={vi.fn()} onMax={onMax} />)
await userEvent.click(screen.getByRole("button", { name: "MAX" }))
expect(onMax).toHaveBeenCalled()
})
})
62 changes: 62 additions & 0 deletions apps/web/src/shared/components/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentPropsWithoutRef<"input">, "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<HTMLInputElement>) {
const nextValue = event.target.value
if (!DECIMAL_INPUT_REGEX.test(nextValue)) return
onValueChange(nextValue)
}

return (
<div className="space-y-1">
<div className="relative">
<Input
type="text"
inputMode="decimal"
pattern="[0-9]*([.][0-9]*)?"
placeholder={placeholder}
value={value}
onChange={handleChange}
className={className}
{...props}
/>
{onMax ? (
<Button
type="button"
size="sm"
variant="outline"
className="absolute right-2 top-1/2 -translate-y-1/2 px-2 text-[11px]"
onClick={onMax}
>
{maxButtonLabel}
</Button>
) : null}
</div>
{typeof usdValue === "number" && !Number.isNaN(usdValue) ? (
<p className="text-right text-xs text-muted-foreground">{formatUsd(usdValue)}</p>
) : null}
</div>
)
}
3 changes: 1 addition & 2 deletions apps/web/src/shared/lib/bignum.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { describe, expect, it } from "vitest"

import {
formatSorobanAmount,
Expand Down
23 changes: 23 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Binary file added bun.lockb
Binary file not shown.
Loading