Skip to content

Commit 51abe48

Browse files
authored
Merge pull request #90 from boys-cyberhub/feat/issues-22-23-24-25
feat: token balances hook, network hook, mismatch banner, and auto-reconnect
2 parents 6ce154b + c5d67a6 commit 51abe48

7 files changed

Lines changed: 169 additions & 4 deletions

File tree

apps/web/src/features/trade/components/trade-panel/TradePanel.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Badge } from "@workspace/ui/components/badge"
88
import { useTradeState } from "../../hooks/useTradeState"
99
import { useTokenPrices } from "../../hooks/useTokenPrices"
1010
import { useTradeFees } from "../../hooks/useTradeFees"
11+
import { useTokenBalances } from "../../../wallet/hooks/useTokenBalances"
1112
import { TradeInfoRows } from "./TradeInfoRows"
1213
import { ConfirmationDialog } from "./ConfirmationDialog"
1314
import {
@@ -191,17 +192,30 @@ export function TradePanel() {
191192
function TradeInputs({ trade }: { trade: ReturnType<typeof useTradeState> }) {
192193
const { fromAmount, fromTokenAddress, toTokenAddress, tradeFlags, setFromAmount, switchTokens } = trade
193194
const { getMidPrice } = useTokenPrices()
195+
const { data: balances } = useTokenBalances()
194196

195197
const fromPrice = getMidPrice(fromTokenAddress)
196198
const fromUsd = parseFloat(fromAmount || "0") * fromPrice
199+
const walletBalance = balances?.[fromTokenAddress]
197200

198201
return (
199202
<div className="mt-3 space-y-2">
200203
{/* Pay */}
201204
<div className="space-y-1">
202-
<label className="text-xs text-muted-foreground">
203-
{tradeFlags.isSwap ? "Pay" : "Collateral"}
204-
</label>
205+
<div className="flex items-center justify-between">
206+
<label className="text-xs text-muted-foreground">
207+
{tradeFlags.isSwap ? "Pay" : "Collateral"}
208+
</label>
209+
{walletBalance !== undefined && (
210+
<span className="text-xs text-muted-foreground">
211+
Balance:{" "}
212+
<span className="font-mono font-medium text-foreground">
213+
{walletBalance.toLocaleString(undefined, { maximumFractionDigits: 6 })}{" "}
214+
{fromTokenAddress}
215+
</span>
216+
</span>
217+
)}
218+
</div>
205219
<div className="relative">
206220
<Input
207221
type="number"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useState } from "react"
2+
import { useLocation } from "@tanstack/react-router"
3+
import { useNetwork } from "../hooks/useNetwork"
4+
import { useWalletStore } from "../store/wallet-store"
5+
import { NETWORK } from "@/app/config/network"
6+
7+
const SESSION_KEY = "so4-network-mismatch-dismissed"
8+
9+
export function NetworkMismatchBanner() {
10+
const { pathname } = useLocation()
11+
const { mismatch, network } = useNetwork()
12+
const { status } = useWalletStore()
13+
const [dismissed, setDismissed] = useState(
14+
() => sessionStorage.getItem(SESSION_KEY) === "1"
15+
)
16+
17+
// Never show on landing page
18+
if (pathname === "/") return null
19+
if (!mismatch || status !== "connected" || dismissed) return null
20+
21+
const walletLabel = network === "mainnet" ? "Mainnet" : "Testnet"
22+
const appLabel = NETWORK.name === "mainnet" ? "Mainnet" : "Testnet"
23+
24+
function dismiss() {
25+
sessionStorage.setItem(SESSION_KEY, "1")
26+
setDismissed(true)
27+
}
28+
29+
return (
30+
<div
31+
role="alert"
32+
className="flex items-center justify-between border-b border-yellow-500/30 bg-yellow-500/10 px-4 py-2 text-sm text-yellow-700 dark:text-yellow-400"
33+
>
34+
<span>
35+
Your wallet is connected to {walletLabel} but this app is running on{" "}
36+
{appLabel}. Please switch networks in your wallet.
37+
</span>
38+
<button
39+
onClick={dismiss}
40+
className="ml-4 shrink-0 font-medium underline-offset-2 hover:underline"
41+
aria-label="Dismiss network mismatch warning"
42+
>
43+
Dismiss
44+
</button>
45+
</div>
46+
)
47+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useEffect } from "react"
2+
import { walletKit } from "../lib/wallet-kit"
3+
import { useWalletStore } from "../store/wallet-store"
4+
5+
interface WalletProviderProps {
6+
children: React.ReactNode
7+
}
8+
9+
export function WalletProvider({ children }: WalletProviderProps) {
10+
const { address, walletId, setConnected, setDisconnected } = useWalletStore()
11+
12+
useEffect(() => {
13+
// Nothing persisted — nothing to reconnect
14+
if (!address || !walletId) return
15+
16+
// Point the kit at the previously-used wallet module, then ask for the
17+
// current address. If the extension is still installed and approved,
18+
// this resolves without a modal. Any error means the wallet is gone —
19+
// clear the store without showing a toast.
20+
walletKit.setWallet(walletId)
21+
walletKit
22+
.getAddress()
23+
.then(({ address: liveAddress }) => {
24+
if (liveAddress === address) {
25+
setConnected(liveAddress, walletId)
26+
} else {
27+
setDisconnected()
28+
}
29+
})
30+
.catch(() => {
31+
setDisconnected()
32+
})
33+
// eslint-disable-next-line react-hooks/exhaustive-deps
34+
}, []) // intentionally run only once on mount
35+
36+
return <>{children}</>
37+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { NETWORK } from "@/app/config/network"
2+
import { useWalletStore } from "../store/wallet-store"
3+
4+
export function useNetwork() {
5+
const { network, status } = useWalletStore()
6+
7+
const isTestnet = network === "testnet"
8+
const isMainnet = network === "mainnet"
9+
// Mismatch only meaningful when a wallet is connected
10+
const mismatch = status === "connected" && network !== NETWORK.name
11+
12+
return { network, isTestnet, isMainnet, mismatch }
13+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useQuery } from "@tanstack/react-query"
2+
import { NETWORK } from "@/app/config/network"
3+
import { useWalletStore } from "../store/wallet-store"
4+
5+
type HorizonBalance = {
6+
asset_type: "native" | "credit_alphanum4" | "credit_alphanum12"
7+
asset_code?: string
8+
balance: string
9+
}
10+
11+
async function fetchTokenBalances(
12+
address: string
13+
): Promise<Record<string, number>> {
14+
const res = await fetch(`${NETWORK.horizonUrl}/accounts/${address}`)
15+
if (!res.ok) throw new Error(`Horizon error ${res.status}`)
16+
const data = await res.json()
17+
18+
const result: Record<string, number> = {}
19+
for (const entry of data.balances as HorizonBalance[]) {
20+
const symbol = entry.asset_type === "native" ? "XLM" : (entry.asset_code ?? "")
21+
if (symbol) result[symbol] = parseFloat(entry.balance)
22+
}
23+
return result
24+
}
25+
26+
export function useTokenBalances() {
27+
const { address, status } = useWalletStore()
28+
29+
return useQuery({
30+
queryKey: ["tokenBalances", address],
31+
queryFn: () => fetchTokenBalances(address!),
32+
enabled: !!address && status === "connected",
33+
staleTime: 15_000,
34+
refetchInterval: 15_000,
35+
})
36+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {
2+
StellarWalletsKit,
3+
WalletNetwork,
4+
FreighterModule,
5+
} from "@creit.tech/stellar-wallets-kit"
6+
import { NETWORK } from "@/app/config/network"
7+
8+
export const walletKit = new StellarWalletsKit({
9+
network:
10+
NETWORK.name === "mainnet" ? WalletNetwork.PUBLIC : WalletNetwork.TESTNET,
11+
selectedWalletId: "freighter",
12+
modules: [new FreighterModule()],
13+
})

apps/web/src/routes/__root.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"
22
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
33
import { Toaster } from "sonner"
44
import { ThemeProvider } from "../ui/theme-provider"
5+
import { WalletProvider } from "../features/wallet/components/WalletProvider"
6+
import { NetworkMismatchBanner } from "../features/wallet/components/NetworkMismatchBanner"
57
import appCss from "@workspace/ui/globals.css?url"
68

79
const queryClient = new QueryClient({
@@ -178,7 +180,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
178180
<body>
179181
<QueryClientProvider client={queryClient}>
180182
<ThemeProvider>
181-
{children}
183+
<WalletProvider>
184+
<NetworkMismatchBanner />
185+
{children}
186+
</WalletProvider>
182187
<Toaster richColors position="bottom-right" />
183188
</ThemeProvider>
184189
</QueryClientProvider>

0 commit comments

Comments
 (0)