From b6f81908007ec22ee514157b239bbd064dcf3c65 Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Wed, 27 May 2026 09:53:22 +0530 Subject: [PATCH 1/5] feat(frontend): wire dapp dashboard to live Go API --- apps/dapp/frontend/app/dashboard/page.tsx | 800 ++++++++++++------ apps/dapp/frontend/app/layout.tsx | 31 +- .../frontend/components/auth-provider.tsx | 157 +++- .../dapp/frontend/components/ui/skeletons.tsx | 83 ++ apps/dapp/frontend/hooks/useNesterAuth.ts | 131 +++ apps/dapp/frontend/hooks/useSettlements.ts | 52 ++ apps/dapp/frontend/hooks/useVaultHistory.ts | 70 ++ apps/dapp/frontend/hooks/useVaults.ts | 101 ++- apps/dapp/frontend/lib/api/client.ts | 274 +++++- apps/dapp/frontend/next.config.ts | 8 + 10 files changed, 1371 insertions(+), 336 deletions(-) create mode 100644 apps/dapp/frontend/components/ui/skeletons.tsx create mode 100644 apps/dapp/frontend/hooks/useNesterAuth.ts create mode 100644 apps/dapp/frontend/hooks/useSettlements.ts create mode 100644 apps/dapp/frontend/hooks/useVaultHistory.ts diff --git a/apps/dapp/frontend/app/dashboard/page.tsx b/apps/dapp/frontend/app/dashboard/page.tsx index ea466709..ed64d5c5 100644 --- a/apps/dapp/frontend/app/dashboard/page.tsx +++ b/apps/dapp/frontend/app/dashboard/page.tsx @@ -3,24 +3,23 @@ import Link from "next/link"; import Image from "next/image"; import { useWallet } from "@/components/wallet-provider"; +import { useAuth } from "@/components/auth-provider"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { motion } from "framer-motion"; import { ArrowDownToLine, ArrowUpRight, BarChart3, Layers, + LogIn, PiggyBank, + RefreshCw, Shield, TrendingUp, Vault, Wallet, } from "lucide-react"; -import { - usePortfolio, - type PortfolioPosition, -} from "@/components/portfolio-provider"; import { WithdrawModal } from "@/components/vault-action-modals"; import { cn } from "@/lib/utils"; import { GuidedTour } from "@/components/onboarding/GuidedTour"; @@ -32,35 +31,435 @@ import { useNetwork } from "@/hooks/useNetwork"; import { AppShell } from "@/components/app-shell"; import { useOfflineStatus } from "@/hooks/useOfflineStatus"; import { formatDistanceToNow } from "date-fns"; +import { useVaults, type VaultWithPerf } from "@/hooks/useVaults"; +import { useSettlements } from "@/hooks/useSettlements"; +import { useVaultHistory } from "@/hooks/useVaultHistory"; +import { + SkeletonStatCard, + SkeletonPositionsTable, + SkeletonActivityItem, +} from "@/components/ui/skeletons"; +import { usePortfolio } from "@/components/portfolio-provider"; +import type { PortfolioPosition } from "@/components/portfolio-provider"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const CHART_PERIODS = ["1W", "1M", "3M", "All"] as const; +type ChartPeriod = (typeof CHART_PERIODS)[number]; -const CHART_PERIODS = ["1D", "1W", "1M", "6M", "1Y", "All"] as const; - -function getVaultIcon(vaultName: string) { - const name = vaultName.toLowerCase(); - if (name.includes("saving") || name.includes("flex")) - return ; - if (name.includes("defi") || name.includes("index")) - return ; - if (name.includes("conservative") || name.includes("stable")) - return ; - if (name.includes("growth") || name.includes("aggressive")) - return ; - if (name.includes("balanced")) - return ; +const PERIOD_API_MAP: Record = { + "1W": "7d", + "1M": "30d", + "3M": "90d", + All: "all", +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getVaultIcon(currency: string) { + const c = currency.toLowerCase(); + if (c === "usdc") return ; + if (c === "xlm") return ; return ; } +function fmtUsd(n: number) { + return n.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +// Convert ApiVault → PortfolioPosition shape for the existing WithdrawModal +function vaultToPosition(v: VaultWithPerf): PortfolioPosition { + const balance = parseFloat(v.current_balance) || 0; + const apy = v.performance?.apy_30d ?? 0; + const yieldEarned = parseFloat(v.yield_earned) || 0; + const depositedAt = v.created_at; + // No lock for now — assume flexible + return { + id: v.id, + vaultId: v.id, + vaultName: `${v.currency} Vault`, + asset: v.currency as "USDC" | "XLM", + principal: parseFloat(v.total_deposited) || 0, + shares: balance, + apy, + depositedAt, + maturityAt: depositedAt, // flexible — already matured + earlyWithdrawalPenaltyPct: 0, + currentValue: balance, + yieldEarned, + isMatured: true, + daysRemaining: 0, + }; +} + +// ── Portfolio chart ─────────────────────────────────────────────────────────── + +function PortfolioChart({ + vaultIds, + period, +}: { + vaultIds: string[]; + period: ChartPeriod; +}) { + const { history, isLoading } = useVaultHistory(vaultIds, PERIOD_API_MAP[period]); + + if (isLoading || history.length === 0) { + // Render a static placeholder curve when no data yet + return ( + + + + + + + + + + + ); + } + + // Normalise to SVG coords + const values = history.map((p) => p.value); + const minV = Math.min(...values); + const maxV = Math.max(...values); + const range = maxV - minV || 1; + const W = 400; + const H = 120; + const pad = 10; + + const pts = history.map((p, i) => { + const x = (i / (history.length - 1)) * (W - pad * 2) + pad; + const y = H - pad - ((p.value - minV) / range) * (H - pad * 2); + return `${x},${y}`; + }); + + const linePath = `M${pts.join(" L")}`; + const areaPath = `M${pts[0]} L${pts.join(" L")} L${W - pad},${H} L${pad},${H}Z`; + + return ( + + + + + + + + + + + ); +} + +// ── Sign-in nudge ───────────────────────────────────────────────────────────── + +function SignInBanner({ + onSignIn, + isLoading, +}: { + onSignIn: () => void; + isLoading: boolean; +}) { + return ( + +
+ +

+ Sign in with your wallet to see live vault data. +

+
+ +
+ ); +} + +// ── Positions table ─────────────────────────────────────────────────────────── + +function PositionsTable({ + vaults, + isLoading, + onWithdraw, +}: { + vaults: VaultWithPerf[]; + isLoading: boolean; + onWithdraw: (v: VaultWithPerf) => void; +}) { + if (isLoading) { + return ; + } + + if (vaults.length === 0) { + return ( +
+

No Positions

+

+ Create a position by depositing an asset from your wallet. +

+
+ ); + } + + return ( +
+ + + + + + + + + + + + {vaults.map((v) => { + const balance = parseFloat(v.current_balance) || 0; + const yieldEarned = parseFloat(v.yield_earned) || 0; + const apy = (v.performance?.apy_30d ?? 0) * 100; + return ( + + + + + + + + + ); + })} + +
VaultBalanceAPY (30d)YieldStatus +
+
+
+ {getVaultIcon(v.currency)} +
+
+

{v.currency} Vault

+

+ {v.contract_address.slice(0, 8)}… +

+
+
+
+ {fmtUsd(balance)} + + {apy.toFixed(1)}% + + +{fmtUsd(yieldEarned)} + + + {v.status.charAt(0).toUpperCase() + v.status.slice(1)} + + + +
+
+ ); +} + +// ── Recent Activity (settlements) ───────────────────────────────────────────── + +const STATUS_LABELS: Record = { + initiated: "Initiated", + liquidity_matched: "Matched", + fiat_dispatched: "Dispatched", + confirmed: "Confirmed", + failed: "Failed", +}; + +function ActivityFeed({ + settlements, + isLoading, +}: { + settlements: ReturnType["settlements"]; + isLoading: boolean; +}) { + const { currentNetwork } = useNetwork(); + + if (isLoading) { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); + } + + if (settlements.length === 0) return null; + + return ( +
+ {settlements.slice(0, 5).map((s) => ( +
+
+
+ +
+
+

Off-ramp

+

+ {s.fiat_currency} · {new Date(s.created_at).toLocaleString()} +

+
+
+
+
+

+ {s.amount} {s.currency} +

+ + {STATUS_LABELS[s.status] ?? s.status} + +
+
+
+ ))} +
+ ); +} + +// ── Wallet Balance Table ────────────────────────────────────────────────────── + +function WalletBalanceTable({ + balances, + tokenPrices, +}: { + balances: Record; + tokenPrices: { XLM: number; USDC: number }; +}) { + const assets = [ + { code: "XLM", name: "Stellar Lumens", logo: "/xlm.png", balance: balances.XLM ?? 0, price: tokenPrices.XLM }, + { code: "USDC", name: "USD Coin", logo: "/usdc.png", balance: balances.USDC ?? 0, price: tokenPrices.USDC }, + ]; + + const hasBalance = assets.some((a) => a.balance > 0); + + if (!hasBalance) { + return ( +
+

No Wallet Balance

+

+ Fund your wallet to start depositing into vaults. +

+
+ ); + } + + return ( + + + + + + + + + + + {assets.map((asset) => ( + + + + + + + ))} + +
AssetBalancePriceUSD Value
+
+ {asset.code} +
+

{asset.code}

+

{asset.name}

+
+
+
+ {asset.balance.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 7 })} + + ${asset.price.toFixed(4)} + + ${fmtUsd(asset.balance * asset.price)} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + export default function Dashboard() { - const { isConnected } = useWallet(); - const { positions, transactions, balances } = usePortfolio(); + const { isConnected, address } = useWallet(); + const { isAuthenticated, userId, isSigningIn, signIn } = useAuth(); const { prices: tokenPrices } = useTokenPrices(); - const { currentNetwork } = useNetwork(); const router = useRouter(); - const [selectedPosition, setSelectedPosition] = useState(null); - const [chartPeriod, setChartPeriod] = useState<(typeof CHART_PERIODS)[number]>("1W"); + const [selectedVault, setSelectedVault] = useState(null); + const [chartPeriod, setChartPeriod] = useState("1M"); const [onboardingOpen, setOnboardingOpen] = useState(false); const { isOffline, lastSynced } = useOfflineStatus(); + // Live data + const { vaults, isLoading: vaultsLoading } = useVaults(userId); + const { settlements, isLoading: settlementsLoading } = useSettlements(userId); + + // Wallet balances still come from portfolio-provider (Horizon direct) + const { balances } = usePortfolio(); + + const positions = useMemo(() => vaults.map(vaultToPosition), [vaults]); + useEffect(() => { if (!isConnected) return; profileApi @@ -75,14 +474,32 @@ export default function Dashboard() { if (!isConnected) router.push("/"); }, [isConnected, router]); - const { protocolBalanceUsd, totalYield, avgApy } = useMemo(() => { - const vaultUsd = positions.reduce((sum, p) => sum + p.currentValue, 0); - const yield_ = positions.reduce((sum, p) => sum + p.yieldEarned, 0); - const apy = positions.length - ? positions.reduce((sum, p) => sum + (p.apy ?? 0), 0) / positions.length - : 0; - return { protocolBalanceUsd: vaultUsd, totalYield: yield_, avgApy: apy }; - }, [positions]); + // Auto sign-in when wallet connects and we have no token yet + useEffect(() => { + if (isConnected && address && !isAuthenticated && !isSigningIn) { + signIn().catch(() => {}); // non-blocking — banner shows on failure + } + }, [isConnected, address, isAuthenticated, isSigningIn, signIn]); + + // Aggregate portfolio metrics from live vaults + const { totalBalanceUsd, totalYield, avgApy } = useMemo(() => { + if (vaults.length === 0) return { totalBalanceUsd: 0, totalYield: 0, avgApy: 0 }; + const totalBalanceUsd = vaults.reduce( + (s, v) => s + (parseFloat(v.current_balance) || 0), + 0 + ); + const totalYield = vaults.reduce( + (s, v) => s + (parseFloat(v.yield_earned) || 0), + 0 + ); + const apys = vaults + .map((v) => v.performance?.apy_30d ?? 0) + .filter((a) => a > 0); + const avgApy = apys.length ? apys.reduce((a, b) => a + b, 0) / apys.length : 0; + return { totalBalanceUsd, totalYield, avgApy }; + }, [vaults]); + + const vaultIds = useMemo(() => vaults.map((v) => v.id), [vaults]); const greeting = useMemo(() => { const hour = new Date().getHours(); @@ -91,13 +508,11 @@ export default function Dashboard() { return "Good evening."; }, []); - const recentTransactions = transactions.slice(0, 5); - if (!isConnected) return null; return ( - {/* ── Greeting + action buttons ── */} + {/* ── Greeting + actions ── */} - {/* ── Balance + Chart row ── */} + {/* Sign-in nudge when wallet connected but not yet signed in */} + {isConnected && !isAuthenticated && ( + + )} + + {/* ── Balance + Chart ── */} {/* Left — balance + stats */}
-
-

- ${protocolBalanceUsd.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -

-

Protocol Balance

- {lastSynced && ( -

- Last updated {formatDistanceToNow(lastSynced)} ago + {vaultsLoading ? ( +

+
+
+
+ ) : ( +
+

+ ${fmtUsd(totalBalanceUsd)}

- )} -
+

Protocol Balance

+ {lastSynced && ( +

+ Last updated {formatDistanceToNow(lastSynced)} ago +

+ )} +
+ )}
Position APY - - {(avgApy * 100).toFixed(2)}% - + {vaultsLoading ? ( +
+ ) : ( + + {(avgApy * 100).toFixed(2)}% + + )}
- Total earnings - - ${totalYield.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - + Total earnings + {vaultsLoading ? ( +
+ ) : ( + + ${fmtUsd(totalYield)} + + )} +>>>>>>> theirs
@@ -164,34 +600,25 @@ export default function Dashboard() { {/* Right — chart */}
- {CHART_PERIODS.map((period) => ( + {CHART_PERIODS.map((p) => ( ))}
- - - - - - - - - - +
- {/* ── Recent Activity ── */} - {recentTransactions.length > 0 && ( - -
-

Recent Activity

-
-
- {recentTransactions.map((tx) => ( -
-
-
- {tx.type === "Deposit" ? ( -
-
-

{tx.type}

-

- {tx.vaultName} · {new Date(tx.timestamp).toLocaleString()} -

-
-
-
-
-

{tx.amount} {tx.asset}

- - {tx.status} - -
- {tx.isOnChain && tx.txHash && ( - - - - )} -
-
- ))} -
-
- )} + {/* ── Recent Activity (settlements) ── */} + +
+

Recent Activity

+
+
+ + {!settlementsLoading && settlements.length === 0 && ( +
+

No recent activity

+

+ Off-ramp settlements will appear here once you initiate a withdrawal. +

+
+ )} +
+
+ {/* Withdraw modal — uses existing PortfolioPosition shape */} setSelectedPosition(null)} - position={selectedPosition} + open={!!selectedVault} + onClose={() => setSelectedVault(null)} + position={selectedVault ? vaultToPosition(selectedVault) : null} /> ; - tokenPrices: { XLM: number; USDC: number }; -}) { - const assets = [ - { code: "XLM", name: "Stellar Lumens", logo: "/xlm.png", balance: balances.XLM ?? 0, price: tokenPrices.XLM }, - { code: "USDC", name: "USD Coin", logo: "/usdc.png", balance: balances.USDC ?? 0, price: tokenPrices.USDC }, - ]; - - const hasBalance = assets.some((a) => a.balance > 0); - - if (!hasBalance) { - return ( -
-

No Wallet Balance

-

- Fund your wallet to start depositing into vaults. -

-
- ); - } - - return ( - - - - - - - - - - - {assets.map((asset) => ( - - - - - - - ))} - -
AssetBalancePriceUSD Value
-
- -
-

{asset.code}

-

{asset.name}

-
-
-
- {asset.balance.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 7 })} - - ${asset.price.toFixed(4)} - - ${(asset.balance * asset.price).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
- ); -} diff --git a/apps/dapp/frontend/app/layout.tsx b/apps/dapp/frontend/app/layout.tsx index 09271d1a..1a642b44 100644 --- a/apps/dapp/frontend/app/layout.tsx +++ b/apps/dapp/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import Script from "next/script"; import { Inter } from "next/font/google"; import { PortfolioProvider } from "@/components/portfolio-provider"; import { WalletProvider } from "@/components/wallet-provider"; +import { AuthProvider } from "@/components/auth-provider"; import { NotificationsProvider } from "@/components/notifications-provider"; import { NotificationsToaster } from "@/components/notifications-toaster"; import { WebSocketProvider } from "@/components/websocket-provider"; @@ -69,20 +70,22 @@ export default function RootLayout({ - - - - - - - {children} - - - - - - - + + + + + + + + {children} + + + + + + + + diff --git a/apps/dapp/frontend/components/auth-provider.tsx b/apps/dapp/frontend/components/auth-provider.tsx index 721c5467..2ef41e6f 100644 --- a/apps/dapp/frontend/components/auth-provider.tsx +++ b/apps/dapp/frontend/components/auth-provider.tsx @@ -1,42 +1,157 @@ "use client"; import { - createContext, - useContext, - useState, - useEffect, - type ReactNode, + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, } from "react"; import { useWallet } from "@/components/wallet-provider"; +import { api } from "@/lib/api/client"; + +const TOKEN_KEY = "nester_auth_token"; +const USER_ID_KEY = "nester_user_id"; interface AuthContextType { - token: string | null; - setToken: (token: string | null) => void; + token: string | null; + userId: string | null; + isAuthenticated: boolean; + isSigningIn: boolean; + authError: string | null; + signIn: () => Promise; + signOut: () => void; } const AuthContext = createContext({ - token: null, - setToken: () => {}, + token: null, + userId: null, + isAuthenticated: false, + isSigningIn: false, + authError: null, + signIn: async () => {}, + signOut: () => {}, }); +function readStorage(key: string): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(key); +} + +function writeStorage(token: string | null, userId: string | null) { + if (typeof window === "undefined") return; + if (token) { + window.localStorage.setItem(TOKEN_KEY, token); + } else { + window.localStorage.removeItem(TOKEN_KEY); + } + if (userId) { + window.localStorage.setItem(USER_ID_KEY, userId); + } else { + window.localStorage.removeItem(USER_ID_KEY); + } +} + export function AuthProvider({ children }: { children: ReactNode }) { - const [token, setTokenState] = useState(null); - const { address } = useWallet(); + const { address } = useWallet(); + + const [token, setToken] = useState(() => readStorage(TOKEN_KEY)); + const [userId, setUserId] = useState(() => readStorage(USER_ID_KEY)); + const [isSigningIn, setIsSigningIn] = useState(false); + const [authError, setAuthError] = useState(null); - // Clear token synchronously if wallet disconnects (using render phase) - const tokenToUse = address ? token : null; + // Clear session when wallet disconnects + useEffect(() => { + if (!address) { + writeStorage(null, null); + setToken(null); + setUserId(null); + } + }, [address]); - const setToken = (newToken: string | null) => { - setTokenState(newToken); + // Sync across browser tabs + useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key === TOKEN_KEY) setToken(e.newValue); + if (e.key === USER_ID_KEY) setUserId(e.newValue); }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, []); + + const signIn = useCallback(async () => { + if (!address || token) return; // already signed in or no wallet + setIsSigningIn(true); + setAuthError(null); + + try { + // 1. Request challenge nonce + const { challenge } = await api.auth.requestChallenge(address); + + // 2. Sign with Freighter/StellarWalletsKit + const { signMessage } = await import("@stellar/freighter-api"); + const raw = await signMessage(challenge, { address }); + // v3 returns string directly; v6 (used in SWK) returns { signature } + const signature = + typeof raw === "string" + ? raw + : (raw as unknown as { signature: string }).signature; + + // 3. Verify and receive JWT + const { token: jwt } = await api.auth.verify(address, signature, challenge); + + // 4. Resolve / create user record + let uid: string | null = null; + try { + const user = await api.users.getByWallet(address); + uid = user.id; + } catch { + try { + const newUser = await api.users.register( + address, + `${address.slice(0, 4)}…${address.slice(-4)}` + ); + uid = newUser.id; + } catch { + // token is still valid even if user create failed + } + } + + writeStorage(jwt, uid); + setToken(jwt); + setUserId(uid); + } catch (err) { + const msg = err instanceof Error ? err.message : "Sign-in failed"; + setAuthError(msg); + } finally { + setIsSigningIn(false); + } + }, [address, token]); + + const signOut = useCallback(() => { + writeStorage(null, null); + setToken(null); + setUserId(null); + }, []); - return ( - - {children} - - ); + return ( + + {children} + + ); } export function useAuth() { - return useContext(AuthContext); + return useContext(AuthContext); } diff --git a/apps/dapp/frontend/components/ui/skeletons.tsx b/apps/dapp/frontend/components/ui/skeletons.tsx new file mode 100644 index 00000000..baed9b31 --- /dev/null +++ b/apps/dapp/frontend/components/ui/skeletons.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +/** Animated shimmer placeholder for a single line. */ +export function SkeletonLine({ + className, +}: { + className?: string; +}) { + return ( +
+ ); +} + +/** Stat card skeleton — mirrors the balance / APY / yield cards. */ +export function SkeletonStatCard() { + return ( +
+ + +
+ ); +} + +/** Table row skeleton for the positions table. */ +export function SkeletonTableRow({ cols = 5 }: { cols?: number }) { + return ( + + {Array.from({ length: cols }).map((_, i) => ( + + + + ))} + + ); +} + +/** Full positions-table skeleton. */ +export function SkeletonPositionsTable({ rows = 3 }: { rows?: number }) { + return ( + + + + {["Vault", "Balance", "APY", "Yield", "Status", ""].map((h) => ( + + ))} + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ +
+ ); +} + +/** Activity feed item skeleton. */ +export function SkeletonActivityItem() { + return ( +
+
+ +
+ + +
+
+
+ + +
+
+ ); +} diff --git a/apps/dapp/frontend/hooks/useNesterAuth.ts b/apps/dapp/frontend/hooks/useNesterAuth.ts new file mode 100644 index 00000000..17b7f699 --- /dev/null +++ b/apps/dapp/frontend/hooks/useNesterAuth.ts @@ -0,0 +1,131 @@ +"use client"; + +/** + * useNesterAuth — challenge/verify login flow + JWT persistence. + * + * 1. Request a nonce (challenge) from POST /api/v1/auth/challenge + * 2. Have the wallet sign it with signMessage() + * 3. POST /api/v1/auth/verify → get a JWT + * 4. Persist the JWT in localStorage so apiFetch() can include it + * + * The hook is idempotent — calling signIn() when already authenticated is a + * no-op. + */ + +import { useState, useCallback, useEffect } from "react"; +import { api } from "@/lib/api/client"; + +const TOKEN_KEY = "nester_auth_token"; +const USER_ID_KEY = "nester_user_id"; + +function loadStoredToken(): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(TOKEN_KEY); +} + +function loadStoredUserId(): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(USER_ID_KEY); +} + +function persistToken(token: string | null, userId: string | null) { + if (typeof window === "undefined") return; + if (token) { + window.localStorage.setItem(TOKEN_KEY, token); + } else { + window.localStorage.removeItem(TOKEN_KEY); + } + if (userId) { + window.localStorage.setItem(USER_ID_KEY, userId); + } else { + window.localStorage.removeItem(USER_ID_KEY); + } +} + +export function useNesterAuth() { + const [token, setToken] = useState(loadStoredToken); + const [userId, setUserId] = useState(loadStoredUserId); + const [isSigningIn, setIsSigningIn] = useState(false); + const [error, setError] = useState(null); + + // Keep state in sync with other tabs + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === TOKEN_KEY) { + setToken(e.newValue); + } + if (e.key === USER_ID_KEY) { + setUserId(e.newValue); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const signIn = useCallback(async (walletAddress: string) => { + if (token) return; // already authenticated + setIsSigningIn(true); + setError(null); + + try { + // Step 1: get challenge nonce + const { challenge } = await api.auth.requestChallenge(walletAddress); + + // Step 2: sign with freighter / stellar-wallets-kit + // We use @stellar/freighter-api's signMessage (signs the raw string) + const { signMessage } = await import("@stellar/freighter-api"); + const result = await signMessage(challenge, { address: walletAddress }); + // signMessage returns { signature: string } (base64) + const signature = + typeof result === "string" ? result : (result as unknown as { signature: string }).signature; + + // Step 3: verify and get JWT + const { token: jwt } = await api.auth.verify(walletAddress, signature, challenge); + + // Step 4: look up or auto-register user + let uid: string | null = null; + try { + const user = await api.users.getByWallet(walletAddress); + uid = user.id; + } catch { + // user not found — auto-register + try { + const newUser = await api.users.register( + walletAddress, + walletAddress.slice(0, 8) // use first 8 chars as display name + ); + uid = newUser.id; + } catch { + // ignore register failure — we still have the token + } + } + + persistToken(jwt, uid); + setToken(jwt); + setUserId(uid); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Authentication failed"; + setError(msg); + throw err; + } finally { + setIsSigningIn(false); + } + }, [token]); + + const signOut = useCallback(() => { + persistToken(null, null); + setToken(null); + setUserId(null); + }, []); + + return { + token, + userId, + isAuthenticated: !!token, + isSigningIn, + authError: error, + signIn, + signOut, + }; +} diff --git a/apps/dapp/frontend/hooks/useSettlements.ts b/apps/dapp/frontend/hooks/useSettlements.ts new file mode 100644 index 00000000..46c3cb4f --- /dev/null +++ b/apps/dapp/frontend/hooks/useSettlements.ts @@ -0,0 +1,52 @@ +"use client"; + +/** + * useSettlements — live settlement history for the authenticated user. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { api, type ApiSettlement, ApiError } from "@/lib/api/client"; + +const POLL_INTERVAL = 30_000; + +interface UseSettlementsResult { + settlements: ApiSettlement[]; + isLoading: boolean; + error: string | null; + refresh: () => void; +} + +export function useSettlements(userId: string | null): UseSettlementsResult { + const [settlements, setSettlements] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const timerRef = useRef | null>(null); + + const fetch = useCallback(async () => { + if (!userId) return; + setIsLoading(true); + setError(null); + try { + const data = await api.settlements.list(userId); + setSettlements(data); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setSettlements([]); + } else { + setError(err instanceof Error ? err.message : "Failed to load settlements"); + } + } finally { + setIsLoading(false); + } + }, [userId]); + + useEffect(() => { + fetch(); + timerRef.current = setInterval(fetch, POLL_INTERVAL); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [fetch]); + + return { settlements, isLoading, error, refresh: fetch }; +} diff --git a/apps/dapp/frontend/hooks/useVaultHistory.ts b/apps/dapp/frontend/hooks/useVaultHistory.ts new file mode 100644 index 00000000..a5616320 --- /dev/null +++ b/apps/dapp/frontend/hooks/useVaultHistory.ts @@ -0,0 +1,70 @@ +"use client"; + +/** + * useVaultHistory — fetches APY/balance history for the portfolio chart. + * Merges snapshots from all user vaults into a single time-series. + */ + +import { useState, useEffect, useCallback } from "react"; +import { api, ApiError } from "@/lib/api/client"; + +export interface ChartPoint { + date: string; // ISO date string + value: number; // cumulative balance in asset units (sum across vaults) +} + +interface UseVaultHistoryResult { + history: ChartPoint[]; + isLoading: boolean; +} + +export function useVaultHistory( + vaultIds: string[], + period: string = "30d" +): UseVaultHistoryResult { + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetch = useCallback(async () => { + if (vaultIds.length === 0) { + setHistory([]); + return; + } + setIsLoading(true); + try { + // Fetch history for every vault in parallel + const allSnapshots = await Promise.all( + vaultIds.map((id) => + api.performance.getHistory(id, period).catch(() => []) + ) + ); + + // Build a date→balance map by summing all vaults + const map = new Map(); + for (const snaps of allSnapshots) { + for (const s of snaps) { + const day = s.recorded_at.slice(0, 10); // "YYYY-MM-DD" + map.set(day, (map.get(day) ?? 0) + s.balance); + } + } + + const sorted = Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, value]) => ({ date, value })); + + setHistory(sorted); + } catch (err) { + if (!(err instanceof ApiError && err.status === 401)) { + console.warn("useVaultHistory:", err); + } + } finally { + setIsLoading(false); + } + }, [vaultIds.join(","), period]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + fetch(); + }, [fetch]); + + return { history, isLoading }; +} diff --git a/apps/dapp/frontend/hooks/useVaults.ts b/apps/dapp/frontend/hooks/useVaults.ts index abcdcd0e..a3d521c1 100644 --- a/apps/dapp/frontend/hooks/useVaults.ts +++ b/apps/dapp/frontend/hooks/useVaults.ts @@ -1,39 +1,72 @@ -import { useQuery } from '@tanstack/react-query'; - -export interface Vault { - id: string; - name: string; - strategy: string; - contractAddress: string; - minDeposit: number; - apy?: number; - tvl?: number; - asset: "USDC" | "XLM"; - managementFeePct?: number; - performanceFeePct?: number; +"use client"; + +/** + * useVaults — live vault data for the authenticated user. + * + * Polls every 30 s so the dashboard stays fresh without WebSocket overhead. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { api, type ApiVault, type ApiPerformanceSummary, ApiError } from "@/lib/api/client"; + +const POLL_INTERVAL = 30_000; // 30 s + +export interface VaultWithPerf extends ApiVault { + performance?: ApiPerformanceSummary; } -export function formatTvl(value: number | undefined): string { - if (value === undefined) return "TVL unavailable"; - if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`; - if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`; - if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`; - return `$${value}`; +interface UseVaultsResult { + vaults: VaultWithPerf[]; + isLoading: boolean; + error: string | null; + refresh: () => void; } -export function useVaults() { - return useQuery({ - queryKey: ['vaults'], - queryFn: async () => { - const res = await fetch('/api/v1/vaults'); - if (!res.ok) throw new Error('Failed to fetch vaults'); - const vaults = await res.json() as Vault[]; - return vaults.map((v) => ({ - ...v, - asset: (v.asset ?? (v.name.toLowerCase().includes("xlm") ? "XLM" : "USDC")) as "USDC" | "XLM", - })); - }, - refetchInterval: 60000, - staleTime: 30000, - }); +export function useVaults(userId: string | null): UseVaultsResult { + const [vaults, setVaults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const timerRef = useRef | null>(null); + + const fetch = useCallback(async () => { + if (!userId) return; + setIsLoading(true); + setError(null); + try { + const raw = await api.vaults.list(userId); + + // Enrich with performance summary (best-effort — don't fail if it errors) + const enriched: VaultWithPerf[] = await Promise.all( + raw.map(async (v) => { + try { + const performance = await api.performance.getSummary(v.id); + return { ...v, performance }; + } catch { + return v; + } + }) + ); + + setVaults(enriched); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + // Token expired — don't surface as a noisy error + setVaults([]); + } else { + setError(err instanceof Error ? err.message : "Failed to load vaults"); + } + } finally { + setIsLoading(false); + } + }, [userId]); + + useEffect(() => { + fetch(); + timerRef.current = setInterval(fetch, POLL_INTERVAL); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [fetch]); + + return { vaults, isLoading, error, refresh: fetch }; } diff --git a/apps/dapp/frontend/lib/api/client.ts b/apps/dapp/frontend/lib/api/client.ts index ada7793c..01c42edf 100644 --- a/apps/dapp/frontend/lib/api/client.ts +++ b/apps/dapp/frontend/lib/api/client.ts @@ -1,14 +1,39 @@ import config from "@/lib/config"; +/** + * Typed API client for the Nester Go backend. + * + * All routes under /api/v1/ require a Bearer JWT. + * The token is read from the auth-store (localStorage) on every request so it + * always reflects the current login state without needing to thread it through + * props/context. + */ + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const API_BASE = + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1"; + export function getStoredToken(): string { if (typeof window === "undefined") return ""; - return localStorage.getItem("nester_token") ?? ""; + return window.localStorage.getItem("nester_token") ?? ""; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string + ) { + super(message); + this.name = "ApiError"; + } } type ApiEnvelope = { success: boolean; data: T; - error?: { message: string }; + error?: { code?: string; message: string }; }; export async function apiRequest( @@ -30,3 +55,248 @@ export async function apiRequest( } return json.data; } + +async function apiFetch( + path: string, + init?: RequestInit & { skipAuth?: boolean } +): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(init?.headers as Record), + }; + + if (!init?.skipAuth) { + const token = getStoredToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + } + + const res = await fetch(`${API_BASE}${path}`, { + ...init, + headers, + }); + + const json = (await res.json()) as ApiEnvelope; + + if (!json.success) { + throw new ApiError( + res.status, + json.error?.code ?? "UNKNOWN", + json.error?.message ?? `API error ${res.status}` + ); + } + + return json.data as T; +} + +// ── Domain types ────────────────────────────────────────────────────────────── + +export interface ApiVault { + id: string; + user_id: string; + contract_address: string; + total_deposited: string; + current_balance: string; + currency: string; + status: "active" | "paused" | "closed"; + yield_earned: string; + fees_paid: string; + last_synced_at?: string; + allocations?: ApiAllocation[]; + created_at: string; + updated_at: string; +} + +export interface ApiAllocation { + id: string; + vault_id: string; + protocol: string; + amount: string; + apy: string; + status: string; + allocated_at: string; + updated_at?: string; +} + +export interface ApiSettlement { + id: string; + user_id: string; + vault_id: string; + amount: string; + currency: string; + fiat_currency: string; + fiat_amount: string; + exchange_rate: string; + destination: { + type: string; + provider: string; + account_number: string; + account_name: string; + bank_code?: string; + }; + status: + | "initiated" + | "liquidity_matched" + | "fiat_dispatched" + | "confirmed" + | "failed"; + retry_count: number; + error_message?: string; + notes?: string; + estimated_fee?: string; + created_at: string; + completed_at?: string; +} + +export interface ApiUser { + id: string; + wallet_address: string; + display_name: string; + created_at: string; + updated_at: string; +} + +export interface ApiPerformanceSummary { + vault_id: string; + current_balance: number; + total_deposited: number; + total_yield: number; + roi_pct: number; + apy_7d: number; + apy_30d: number; + apy_90d: number; + snapshot_count: number; +} + +export interface ApiPerformanceSnapshot { + id: string; + vault_id: string; + balance: number; + apy: number; + recorded_at: string; +} + +export interface ApiTransaction { + id: string; + vault_id: string; + type: "deposit" | "withdrawal" | "settlement"; + amount: string; + currency: string; + tx_hash: string; + created_at: string; +} + +// Auth types +export interface ChallengeResponse { + challenge: string; +} + +export interface VerifyResponse { + token: string; +} + +// ── API surface ─────────────────────────────────────────────────────────────── + +export const api = { + /** Challenge / verify wallet login */ + auth: { + requestChallenge: (walletAddress: string) => + apiFetch("/auth/challenge", { + method: "POST", + body: JSON.stringify({ wallet_address: walletAddress }), + skipAuth: true, + }), + + verify: ( + walletAddress: string, + signature: string, + challenge: string + ) => + apiFetch("/auth/verify", { + method: "POST", + body: JSON.stringify({ wallet_address: walletAddress, signature, challenge }), + skipAuth: true, + }), + }, + + /** User lookups */ + users: { + getByWallet: (address: string) => + apiFetch(`/users/wallet/${address}`), + + getById: (id: string) => + apiFetch(`/users/${id}`), + + register: (walletAddress: string, displayName: string) => + apiFetch("/users", { + method: "POST", + body: JSON.stringify({ wallet_address: walletAddress, display_name: displayName }), + skipAuth: true, + }), + }, + + /** Vault CRUD */ + vaults: { + list: (userId: string) => + apiFetch(`/vaults?userId=${userId}`), + + getById: (vaultId: string) => + apiFetch(`/vaults/${vaultId}`), + + getAllocations: (vaultId: string) => + apiFetch(`/vaults/${vaultId}/allocations`), + + create: (contractAddress: string, currency: string) => + apiFetch("/vaults", { + method: "POST", + body: JSON.stringify({ contract_address: contractAddress, currency }), + }), + }, + + /** Performance metrics */ + performance: { + getSummary: (vaultId: string) => + apiFetch(`/vaults/${vaultId}/performance`), + + getHistory: (vaultId: string, period = "30d") => + apiFetch( + `/vaults/${vaultId}/performance/history?period=${period}` + ), + + getApy: (vaultId: string) => + apiFetch>(`/vaults/${vaultId}/performance/apy`), + }, + + /** Settlements */ + settlements: { + list: (userId: string, status?: string) => + apiFetch( + `/settlements?userId=${userId}${status ? \`&status=\${status}\` : ""}` + ), + + getById: (settlementId: string) => + apiFetch(`/settlements/${settlementId}`), + + create: (req: { + user_id: string; + vault_id: string; + amount: string; + currency: string; + fiat_currency: string; + fiat_amount: string; + exchange_rate: string; + destination: { + type: string; + provider: string; + account_number: string; + account_name: string; + bank_code?: string; + }; + }) => + apiFetch("/settlements", { + method: "POST", + body: JSON.stringify(req), + }), + }, +}; diff --git a/apps/dapp/frontend/next.config.ts b/apps/dapp/frontend/next.config.ts index 3373ba89..aea26d0f 100644 --- a/apps/dapp/frontend/next.config.ts +++ b/apps/dapp/frontend/next.config.ts @@ -14,9 +14,17 @@ const nextConfig: NextConfig = { async rewrites() { const intelligenceUrl = process.env.INTELLIGENCE_SERVICE_URL ?? "http://localhost:8000"; + const apiUrl = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/api\/v1$/, "") ?? "http://localhost:8080"; return [ + // Go backend — all /api/v1/* calls { source: "/api/v1/:path*", + destination: `${apiUrl}/api/v1/:path*`, + }, + // Intelligence / AI service + { + source: "/api/intelligence/:path*", destination: `${intelligenceUrl}/:path*`, }, ]; From d5322dc52b9aa572411a44b8353be8a117978ef4 Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Fri, 29 May 2026 23:17:56 +0530 Subject: [PATCH 2/5] Refresh PR - resolve conflicts From bf0ba9ef3abcc7869e237a129f157eb3f1494420 Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Fri, 29 May 2026 23:28:36 +0530 Subject: [PATCH 3/5] fix: implement Copilot review suggestions - Fix API URL handling: use relative paths for browser, absolute for server - Fix next.config.ts regex to properly strip /api/v1 with optional trailing slash - Handle non-JSON/empty API responses gracefully - Rename fetch callback to fetchVaults to avoid shadowing global fetch - Fix polling interval to only start when userId is present - Fix useVaultHistory dependency array using stable vaultIds - Fix SVG chart rendering for single-point case - Fix USD balance calculation to use token prices instead of summing raw balances - Remove unused currentNetwork variable from ActivityFeed - Consolidate auth logic by having useNesterAuth delegate to AuthProvider --- apps/dapp/frontend/app/dashboard/page.tsx | 31 +++-- apps/dapp/frontend/hooks/useNesterAuth.ts | 127 ++------------------ apps/dapp/frontend/hooks/useVaultHistory.ts | 9 +- apps/dapp/frontend/hooks/useVaults.ts | 15 ++- apps/dapp/frontend/lib/api/client.ts | 51 +++++++- apps/dapp/frontend/next.config.ts | 5 +- 6 files changed, 97 insertions(+), 141 deletions(-) diff --git a/apps/dapp/frontend/app/dashboard/page.tsx b/apps/dapp/frontend/app/dashboard/page.tsx index ed64d5c5..2fc771fb 100644 --- a/apps/dapp/frontend/app/dashboard/page.tsx +++ b/apps/dapp/frontend/app/dashboard/page.tsx @@ -141,14 +141,21 @@ function PortfolioChart({ const H = 120; const pad = 10; + // Handle single-point case + const isSinglePoint = history.length === 1; const pts = history.map((p, i) => { - const x = (i / (history.length - 1)) * (W - pad * 2) + pad; + const x = isSinglePoint ? pad : (i / (history.length - 1)) * (W - pad * 2) + pad; const y = H - pad - ((p.value - minV) / range) * (H - pad * 2); - return `${x},${y}`; + return { x, y }; }); - const linePath = `M${pts.join(" L")}`; - const areaPath = `M${pts[0]} L${pts.join(" L")} L${W - pad},${H} L${pad},${H}Z`; + // For single point, render a horizontal line; otherwise use normal path + const pathPoints = isSinglePoint + ? [`${pad},${pts[0].y}`, `${W - pad},${pts[0].y}`] + : pts.map(({ x, y }) => `${x},${y}`); + + const linePath = `M${pathPoints.join(" L")}`; + const areaPath = `M${pathPoints[0]} L${pathPoints.join(" L")} L${W - pad},${H} L${pad},${H}Z`; return ( @@ -317,7 +324,6 @@ function ActivityFeed({ settlements: ReturnType["settlements"]; isLoading: boolean; }) { - const { currentNetwork } = useNetwork(); if (isLoading) { return ( @@ -484,10 +490,15 @@ export default function Dashboard() { // Aggregate portfolio metrics from live vaults const { totalBalanceUsd, totalYield, avgApy } = useMemo(() => { if (vaults.length === 0) return { totalBalanceUsd: 0, totalYield: 0, avgApy: 0 }; - const totalBalanceUsd = vaults.reduce( - (s, v) => s + (parseFloat(v.current_balance) || 0), - 0 - ); + + // Convert each vault balance to USD using token prices + const totalBalanceUsd = vaults.reduce((s, v) => { + const balance = parseFloat(v.current_balance) || 0; + const currency = v.currency.toUpperCase(); + const price = tokenPrices[currency] ?? 0; + return s + (balance * price); + }, 0); + const totalYield = vaults.reduce( (s, v) => s + (parseFloat(v.yield_earned) || 0), 0 @@ -497,7 +508,7 @@ export default function Dashboard() { .filter((a) => a > 0); const avgApy = apys.length ? apys.reduce((a, b) => a + b, 0) / apys.length : 0; return { totalBalanceUsd, totalYield, avgApy }; - }, [vaults]); + }, [vaults, tokenPrices]); const vaultIds = useMemo(() => vaults.map((v) => v.id), [vaults]); diff --git a/apps/dapp/frontend/hooks/useNesterAuth.ts b/apps/dapp/frontend/hooks/useNesterAuth.ts index 17b7f699..57d43de7 100644 --- a/apps/dapp/frontend/hooks/useNesterAuth.ts +++ b/apps/dapp/frontend/hooks/useNesterAuth.ts @@ -1,131 +1,26 @@ "use client"; /** - * useNesterAuth — challenge/verify login flow + JWT persistence. + * useNesterAuth — Deprecated: use useAuth() from @/components/auth-provider instead. * - * 1. Request a nonce (challenge) from POST /api/v1/auth/challenge - * 2. Have the wallet sign it with signMessage() - * 3. POST /api/v1/auth/verify → get a JWT - * 4. Persist the JWT in localStorage so apiFetch() can include it - * - * The hook is idempotent — calling signIn() when already authenticated is a - * no-op. + * This hook is kept for backward compatibility but delegates to the centralized + * AuthProvider which owns all auth state, challenge/verify logic, and persistence. */ -import { useState, useCallback, useEffect } from "react"; -import { api } from "@/lib/api/client"; - -const TOKEN_KEY = "nester_auth_token"; -const USER_ID_KEY = "nester_user_id"; - -function loadStoredToken(): string | null { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(TOKEN_KEY); -} - -function loadStoredUserId(): string | null { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(USER_ID_KEY); -} - -function persistToken(token: string | null, userId: string | null) { - if (typeof window === "undefined") return; - if (token) { - window.localStorage.setItem(TOKEN_KEY, token); - } else { - window.localStorage.removeItem(TOKEN_KEY); - } - if (userId) { - window.localStorage.setItem(USER_ID_KEY, userId); - } else { - window.localStorage.removeItem(USER_ID_KEY); - } -} +import { useAuth } from "@/components/auth-provider"; export function useNesterAuth() { - const [token, setToken] = useState(loadStoredToken); - const [userId, setUserId] = useState(loadStoredUserId); - const [isSigningIn, setIsSigningIn] = useState(false); - const [error, setError] = useState(null); - - // Keep state in sync with other tabs - useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === TOKEN_KEY) { - setToken(e.newValue); - } - if (e.key === USER_ID_KEY) { - setUserId(e.newValue); - } - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - const signIn = useCallback(async (walletAddress: string) => { - if (token) return; // already authenticated - setIsSigningIn(true); - setError(null); - - try { - // Step 1: get challenge nonce - const { challenge } = await api.auth.requestChallenge(walletAddress); - - // Step 2: sign with freighter / stellar-wallets-kit - // We use @stellar/freighter-api's signMessage (signs the raw string) - const { signMessage } = await import("@stellar/freighter-api"); - const result = await signMessage(challenge, { address: walletAddress }); - // signMessage returns { signature: string } (base64) - const signature = - typeof result === "string" ? result : (result as unknown as { signature: string }).signature; - - // Step 3: verify and get JWT - const { token: jwt } = await api.auth.verify(walletAddress, signature, challenge); - - // Step 4: look up or auto-register user - let uid: string | null = null; - try { - const user = await api.users.getByWallet(walletAddress); - uid = user.id; - } catch { - // user not found — auto-register - try { - const newUser = await api.users.register( - walletAddress, - walletAddress.slice(0, 8) // use first 8 chars as display name - ); - uid = newUser.id; - } catch { - // ignore register failure — we still have the token - } - } - - persistToken(jwt, uid); - setToken(jwt); - setUserId(uid); - } catch (err) { - const msg = - err instanceof Error ? err.message : "Authentication failed"; - setError(msg); - throw err; - } finally { - setIsSigningIn(false); - } - }, [token]); - - const signOut = useCallback(() => { - persistToken(null, null); - setToken(null); - setUserId(null); - }, []); + const { token, userId, isSigningIn, authError, signIn } = useAuth(); return { token, userId, - isAuthenticated: !!token, isSigningIn, - authError: error, - signIn, - signOut, + error: authError, + signIn: async (walletAddress: string) => { + // Note: walletAddress is not needed here since AuthProvider + // gets it from useWallet context directly + return signIn(); + }, }; } diff --git a/apps/dapp/frontend/hooks/useVaultHistory.ts b/apps/dapp/frontend/hooks/useVaultHistory.ts index a5616320..2a3cd8c2 100644 --- a/apps/dapp/frontend/hooks/useVaultHistory.ts +++ b/apps/dapp/frontend/hooks/useVaultHistory.ts @@ -5,7 +5,7 @@ * Merges snapshots from all user vaults into a single time-series. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { api, ApiError } from "@/lib/api/client"; export interface ChartPoint { @@ -25,6 +25,11 @@ export function useVaultHistory( const [history, setHistory] = useState([]); const [isLoading, setIsLoading] = useState(false); + const stableVaultIds = useMemo( + () => JSON.stringify([...vaultIds].sort()), + [vaultIds] + ); + const fetch = useCallback(async () => { if (vaultIds.length === 0) { setHistory([]); @@ -60,7 +65,7 @@ export function useVaultHistory( } finally { setIsLoading(false); } - }, [vaultIds.join(","), period]); // eslint-disable-line react-hooks/exhaustive-deps + }, [stableVaultIds, period]); useEffect(() => { fetch(); diff --git a/apps/dapp/frontend/hooks/useVaults.ts b/apps/dapp/frontend/hooks/useVaults.ts index a3d521c1..a36cdc51 100644 --- a/apps/dapp/frontend/hooks/useVaults.ts +++ b/apps/dapp/frontend/hooks/useVaults.ts @@ -28,7 +28,7 @@ export function useVaults(userId: string | null): UseVaultsResult { const [error, setError] = useState(null); const timerRef = useRef | null>(null); - const fetch = useCallback(async () => { + const fetchVaults = useCallback(async () => { if (!userId) return; setIsLoading(true); setError(null); @@ -61,12 +61,17 @@ export function useVaults(userId: string | null): UseVaultsResult { }, [userId]); useEffect(() => { - fetch(); - timerRef.current = setInterval(fetch, POLL_INTERVAL); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + if (!userId) return; + fetchVaults(); + timerRef.current = setInterval(fetchVaults, POLL_INTERVAL); return () => { if (timerRef.current) clearInterval(timerRef.current); }; - }, [fetch]); + }, [userId, fetchVaults]); - return { vaults, isLoading, error, refresh: fetch }; + return { vaults, isLoading, error, refresh: fetchVaults }; } diff --git a/apps/dapp/frontend/lib/api/client.ts b/apps/dapp/frontend/lib/api/client.ts index 01c42edf..6b4ce839 100644 --- a/apps/dapp/frontend/lib/api/client.ts +++ b/apps/dapp/frontend/lib/api/client.ts @@ -11,8 +11,18 @@ import config from "@/lib/config"; // ── Helpers ─────────────────────────────────────────────────────────────────── -const API_BASE = - process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1"; +function getApiBase(): string { + if (process.env.NEXT_PUBLIC_API_URL) { + return process.env.NEXT_PUBLIC_API_URL; + } + // Use relative URL for browser (to leverage Next.js rewrites) + // Use absolute URL for server-side + return typeof window === "undefined" + ? "http://localhost:8080/api/v1" + : "/api/v1"; +} + +const API_BASE = getApiBase(); export function getStoredToken(): string { if (typeof window === "undefined") return ""; @@ -77,13 +87,42 @@ async function apiFetch( headers, }); - const json = (await res.json()) as ApiEnvelope; + // Handle non-JSON or empty responses + const body = await res.text(); + let json: ApiEnvelope | null = null; + + if (body.trim()) { + try { + json = JSON.parse(body) as { + success: boolean; + data?: T; + error?: { code: string; message: string }; + }; + } catch { + if (!res.ok) { + throw new ApiError( + res.status, + "INVALID_RESPONSE", + `API returned a non-JSON response` + ); + } + } + } + + if (!res.ok) { + throw new ApiError( + res.status, + json?.error?.code ?? "UNKNOWN", + json?.error?.message ?? + `API error ${res.status}${res.statusText ? ` ${res.statusText}` : ""}` + ); + } - if (!json.success) { + if (!json?.success) { throw new ApiError( res.status, - json.error?.code ?? "UNKNOWN", - json.error?.message ?? `API error ${res.status}` + json?.error?.code ?? "UNKNOWN", + json?.error?.message ?? `API error ${res.status}` ); } diff --git a/apps/dapp/frontend/next.config.ts b/apps/dapp/frontend/next.config.ts index aea26d0f..d5b1f96e 100644 --- a/apps/dapp/frontend/next.config.ts +++ b/apps/dapp/frontend/next.config.ts @@ -14,8 +14,9 @@ const nextConfig: NextConfig = { async rewrites() { const intelligenceUrl = process.env.INTELLIGENCE_SERVICE_URL ?? "http://localhost:8000"; - const apiUrl = - process.env.NEXT_PUBLIC_API_URL?.replace(/\/api\/v1$/, "") ?? "http://localhost:8080"; + const apiUrl = process.env.NEXT_PUBLIC_API_URL + ? process.env.NEXT_PUBLIC_API_URL.replace(/\/api\/v1\/?$/, "") + : "http://localhost:8080"; return [ // Go backend — all /api/v1/* calls { From cb39c1b969928744c87670af785e61804bf03304 Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Tue, 2 Jun 2026 19:38:52 +0530 Subject: [PATCH 4/5] fix: resolve compilation issues in dashboard and client after rebase --- apps/dapp/frontend/app/dashboard/page.tsx | 3 +-- apps/dapp/frontend/lib/api/client.ts | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/dapp/frontend/app/dashboard/page.tsx b/apps/dapp/frontend/app/dashboard/page.tsx index 2fc771fb..fe15ac87 100644 --- a/apps/dapp/frontend/app/dashboard/page.tsx +++ b/apps/dapp/frontend/app/dashboard/page.tsx @@ -495,7 +495,7 @@ export default function Dashboard() { const totalBalanceUsd = vaults.reduce((s, v) => { const balance = parseFloat(v.current_balance) || 0; const currency = v.currency.toUpperCase(); - const price = tokenPrices[currency] ?? 0; + const price = (tokenPrices as unknown as Record)[currency] ?? 0; return s + (balance * price); }, 0); @@ -603,7 +603,6 @@ export default function Dashboard() { ${fmtUsd(totalYield)} )} ->>>>>>> theirs
diff --git a/apps/dapp/frontend/lib/api/client.ts b/apps/dapp/frontend/lib/api/client.ts index 6b4ce839..885475bd 100644 --- a/apps/dapp/frontend/lib/api/client.ts +++ b/apps/dapp/frontend/lib/api/client.ts @@ -93,11 +93,7 @@ async function apiFetch( if (body.trim()) { try { - json = JSON.parse(body) as { - success: boolean; - data?: T; - error?: { code: string; message: string }; - }; + json = JSON.parse(body) as ApiEnvelope; } catch { if (!res.ok) { throw new ApiError( @@ -311,7 +307,7 @@ export const api = { settlements: { list: (userId: string, status?: string) => apiFetch( - `/settlements?userId=${userId}${status ? \`&status=\${status}\` : ""}` + `/settlements?userId=${userId}${status ? `&status=${status}` : ""}` ), getById: (settlementId: string) => From 223b07c3a79771e0d56728e3da8cb1e657263b5c Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Wed, 3 Jun 2026 17:40:01 +0530 Subject: [PATCH 5/5] fix(frontend): remove all mock-vaults usages, wire portfolio and vaults to live API --- .../frontend/__tests__/auth-provider.test.tsx | 8 +- .../__tests__/connect-wallet.test.tsx | 2 + .../frontend/__tests__/deposit-modal.test.tsx | 20 +- apps/dapp/frontend/app/portfolio/page.tsx | 53 +- apps/dapp/frontend/app/vaults/[id]/page.tsx | 10 +- apps/dapp/frontend/app/vaults/page.tsx | 9 +- .../components/vault/depositModal.tsx | 2 +- .../components/vault/withdrawModal.tsx | 5 +- .../components/vaults/vault-metrics.tsx | 2 +- apps/dapp/frontend/hooks/use-vault-filters.ts | 51 +- apps/dapp/frontend/hooks/useVault.ts | 48 + apps/dapp/frontend/hooks/useVaults.ts | 7 +- apps/dapp/frontend/lib/api/client.ts | 4 +- apps/dapp/frontend/package-lock.json | 16816 ++++++++++++++++ 14 files changed, 17009 insertions(+), 28 deletions(-) create mode 100644 apps/dapp/frontend/hooks/useVault.ts create mode 100644 apps/dapp/frontend/package-lock.json diff --git a/apps/dapp/frontend/__tests__/auth-provider.test.tsx b/apps/dapp/frontend/__tests__/auth-provider.test.tsx index 5a7a6524..dd551dcc 100644 --- a/apps/dapp/frontend/__tests__/auth-provider.test.tsx +++ b/apps/dapp/frontend/__tests__/auth-provider.test.tsx @@ -7,11 +7,14 @@ vi.mock("@/components/wallet-provider", () => ({ })); function AuthConsumer() { - const { token, setToken } = useAuth(); + const { token } = useAuth(); return (
{token ?? "none"} - +
); } @@ -38,6 +41,7 @@ describe("AuthProvider", () => { isConnected: false, wallets: [], walletsLoaded: true, + selectedWalletId: null, } as ReturnType); render( diff --git a/apps/dapp/frontend/__tests__/connect-wallet.test.tsx b/apps/dapp/frontend/__tests__/connect-wallet.test.tsx index 79daf6e8..463cc545 100644 --- a/apps/dapp/frontend/__tests__/connect-wallet.test.tsx +++ b/apps/dapp/frontend/__tests__/connect-wallet.test.tsx @@ -14,6 +14,7 @@ vi.mock("@/components/wallet-provider", () => ({ walletsLoaded: true, isConnected: false, address: null, + selectedWalletId: null, })), })); @@ -60,6 +61,7 @@ describe("ConnectWallet", () => { walletsLoaded: true, isConnected: true, address: "GABC1234567890", + selectedWalletId: "freighter", } as ReturnType); render(); diff --git a/apps/dapp/frontend/__tests__/deposit-modal.test.tsx b/apps/dapp/frontend/__tests__/deposit-modal.test.tsx index 0c8b0bb8..16eb06ae 100644 --- a/apps/dapp/frontend/__tests__/deposit-modal.test.tsx +++ b/apps/dapp/frontend/__tests__/deposit-modal.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { DepositModal } from "@/components/vault/depositModal"; -import { VAULTS } from "@/lib/mock-vaults"; +import type { Vault as VaultType } from "@/lib/types/vault"; vi.mock("@/components/wallet-provider", () => ({ useWallet: () => ({ @@ -25,7 +25,23 @@ vi.mock("@/lib/stellar/transaction", () => ({ truncateTxHash: (h: string) => h.slice(0, 8), })); -const mockVault = VAULTS[0]; +const mockVault: VaultType = { + id: "usdc", + name: "USDC Market", + description: "Test market", + marketType: "single", + tokens: ["USDC"], + currentApy: 10, + apyRange: "8-12%", + tvl: 1000000, + utilization: 80, + allocations: [], + supportedAssets: ["USDC"], + maturityTerms: "Flexible", + earlyWithdrawalPenalty: "None", + apyHistory: [], + strategies: [] +}; describe("DepositModal", () => { it("validates amount input", () => { diff --git a/apps/dapp/frontend/app/portfolio/page.tsx b/apps/dapp/frontend/app/portfolio/page.tsx index b067afd9..3d398c90 100644 --- a/apps/dapp/frontend/app/portfolio/page.tsx +++ b/apps/dapp/frontend/app/portfolio/page.tsx @@ -1,7 +1,10 @@ "use client"; import { useWallet } from "@/components/wallet-provider"; +import { useAuth } from "@/components/auth-provider"; import { usePortfolio, type PortfolioPosition } from "@/components/portfolio-provider"; +import { useVaults, type VaultWithPerf } from "@/hooks/useVaults"; +import { useSettlements } from "@/hooks/useSettlements"; import { AppShell } from "@/components/app-shell"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -65,6 +68,31 @@ function truncAddr(addr: string) { return `${addr.slice(0, 6)}…${addr.slice(-6)}`; } +// Convert ApiVault → PortfolioPosition shape +function vaultToPosition(v: VaultWithPerf): PortfolioPosition { + const balance = parseFloat(v.current_balance) || 0; + const apy = v.performance?.apy_30d ?? 0; + const yieldEarned = parseFloat(v.yield_earned) || 0; + const depositedAt = v.created_at; + // No lock for now — assume flexible + return { + id: v.id, + vaultId: v.id, + vaultName: `${v.currency} Vault`, + asset: v.currency as "USDC" | "XLM", + principal: parseFloat(v.total_deposited) || 0, + shares: balance, + apy, + depositedAt, + maturityAt: depositedAt, // flexible — already matured + earlyWithdrawalPenaltyPct: 0, + currentValue: balance, + yieldEarned, + isMatured: true, + daysRemaining: 0, + }; +} + // ── Transaction type config ────────────────────────────────────────────────── const TX_ICONS = { @@ -78,7 +106,28 @@ const TX_ICONS = { export default function PortfolioPage() { const { isConnected, address } = useWallet(); - const { transactions, positions } = usePortfolio(); + const { userId } = useAuth(); + + // Live API hooks + const { vaults, isLoading: vaultsLoading } = useVaults(userId ?? undefined); + const { settlements, isLoading: settlementsLoading } = useSettlements(userId); + + const positions = useMemo(() => { + return vaults.filter(v => parseFloat(v.current_balance) > 0).map(vaultToPosition); + }, [vaults]); + + const transactions = useMemo(() => settlements.map((s) => ({ + id: s.id, + type: "Settlement" as const, + vaultName: `${s.currency} Settlement`, + asset: s.currency, + amount: s.amount, + status: (s.status === "confirmed" ? "Confirmed" : s.status === "failed" ? "Failed" : "Pending") as "Confirmed" | "Pending" | "Failed", + timestamp: s.created_at, + isOnChain: false, + txHash: undefined as string | undefined, + })), [settlements]); + const { prices: tokenPrices } = useTokenPrices(); const { currentNetwork } = useNetwork(); const router = useRouter(); @@ -437,7 +486,7 @@ export default function PortfolioPage() { target="_blank" rel="noreferrer" className="flex h-6 w-6 items-center justify-center rounded-md text-black/40 hover:bg-black/[0.04] hover:text-black/70 transition-colors focus-visible:ring-2 focus-visible:ring-black" - aria-label={`View transaction ${tx.txHash.slice(0, 8)} on explorer`} + aria-label={`View transaction ${tx.txHash?.slice(0, 8)} on explorer`} >