diff --git a/veilend-web/package-lock.json b/veilend-web/package-lock.json index 83dc600..5612945 100644 --- a/veilend-web/package-lock.json +++ b/veilend-web/package-lock.json @@ -26,13 +26,14 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/ui": "^3.2.7", "eslint": "^9", "eslint-config-next": "16.2.9", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.4", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^3.2.6" + "vitest": "^3.2.7" } }, "node_modules/@alloc/quick-lru": { @@ -2259,6 +2260,13 @@ "node": ">=12.4.0" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", @@ -5270,15 +5278,15 @@ ] }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -5287,13 +5295,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -5314,9 +5322,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -5327,13 +5335,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -5342,13 +5350,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -5357,9 +5365,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5369,14 +5377,36 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.7.tgz", + "integrity": "sha512-eVtcpJXGhS0GjMuHROfbXLhlxooyUcuip8GNzzjDD5jzafZqzanJH4W3VGmUxHNx4fv6qQGUGJHRpGUdj+9D6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.7" + } + }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -7826,6 +7856,13 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -9873,6 +9910,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11741,6 +11788,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -12317,6 +12379,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -12893,20 +12965,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -12936,8 +13008,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, diff --git a/veilend-web/package.json b/veilend-web/package.json index 05d6b2f..a747b19 100644 --- a/veilend-web/package.json +++ b/veilend-web/package.json @@ -30,12 +30,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/ui": "^3.2.7", "eslint": "^9", "eslint-config-next": "16.2.9", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.4", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^3.2.6" + "vitest": "^3.2.7" } } diff --git a/veilend-web/src/app/dashboard/error.tsx b/veilend-web/src/app/dashboard/error.tsx index 1e32bba..fecfd67 100644 --- a/veilend-web/src/app/dashboard/error.tsx +++ b/veilend-web/src/app/dashboard/error.tsx @@ -2,41 +2,80 @@ import { useEffect } from 'react'; import { Container, Flex, Section } from '@/components/Layout'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { AlertCircle, RefreshCw } from 'lucide-react'; -export default function DashboardError({ - error, - reset, -}: { +interface ErrorProps { error: Error & { digest?: string }; reset: () => void; -}) { +} + +export default function DashboardError({ error, reset }: ErrorProps) { useEffect(() => { // Log the error to an error reporting service - console.error('Dashboard failed to load:', error); + console.error('Dashboard Error:', error); }, [error]); + const isAuthError = error.message?.includes('address') || error.message?.includes('wallet'); + const isNetworkError = error.message?.includes('fetch') || error.message?.includes('network'); + return (
- - - Failed to load dashboard - - We encountered an error while fetching your live portfolio and activity data. - Please try again or check your network connection. + + + + + {isAuthError ? 'Wallet Connection Error' : + isNetworkError ? 'Network Error' : + 'Dashboard Error'} + + +

+ {isAuthError + ? 'Unable to load dashboard data. Please ensure your wallet is properly connected and try again.' + : isNetworkError + ? 'Network connection issue. Please check your internet connection and try again.' + : error.message || 'An unexpected error occurred while loading the dashboard.' + } +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )}
-
- +
+ +
+

If the issue persists, please:

+
    +
  • Verify your wallet is connected
  • +
  • Check the backend API is running
  • +
  • Ensure you have a stable network connection
  • +
+
); -} +} \ No newline at end of file diff --git a/veilend-web/src/app/dashboard/loading.tsx b/veilend-web/src/app/dashboard/loading.tsx index 181f0d8..1f2deee 100644 --- a/veilend-web/src/app/dashboard/loading.tsx +++ b/veilend-web/src/app/dashboard/loading.tsx @@ -1,7 +1,6 @@ import { Container, Flex, Grid, Section } from '@/components/Layout'; -import { LoaderCircle } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; export default function DashboardLoading() { return ( @@ -9,47 +8,87 @@ export default function DashboardLoading() {
- -

Dashboard

- -
-

Fetching live portfolio data...

+
+ + +
+ {/* Portfolio Section Loading */}
{[1, 2, 3].map((i) => ( - + + + + + + + + + ))} + +
+ + {/* Asset Breakdown Loading */} +
+ + {[1, 2].map((i) => ( + + + + + - - + + {[1, 2, 3].map((j) => ( + + + +
+ + +
+
+ +
+ ))} +
))}
+ {/* Recent Activity Loading */}
- Recent Activity + + + - {[1, 2, 3, 4].map((i) => ( - + {[1, 2, 3, 4, 5].map((i) => ( + -
- - + + + + +
- +
+ + +
))}
@@ -59,4 +98,4 @@ export default function DashboardLoading() { ); -} +} \ No newline at end of file diff --git a/veilend-web/src/app/dashboard/page.tsx b/veilend-web/src/app/dashboard/page.tsx index 317708c..d59cc52 100644 --- a/veilend-web/src/app/dashboard/page.tsx +++ b/veilend-web/src/app/dashboard/page.tsx @@ -1,31 +1,101 @@ import { Container, Flex, Grid, Section } from '@/components/Layout'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { fetchDashboardData } from '@/lib/api/dashboard'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; export const dynamic = 'force-dynamic'; +// Helper to get wallet address from session/cookie +async function getWalletAddress(): Promise { + // In a production app, this would get the address from a secure session + // For now, we'll check for a wallet address in the headers + const headersList = await headers(); + const walletAddress = headersList.get('x-wallet-address'); + + if (walletAddress && walletAddress.startsWith('G')) { + return walletAddress; + } + + return null; +} + +// Helper functions moved outside component +const getActionBadgeClassName = (action: string): string | undefined => { + switch (action) { + case 'DEPOSIT': + return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400'; + case 'BORROW': + return 'border-amber-500/20 bg-amber-500/10 text-amber-400'; + case 'REPAY': + return 'border-purple-500/20 bg-purple-500/10 text-purple-400'; + case 'WITHDRAW': + return 'border-cyan-500/20 bg-cyan-500/10 text-cyan-400'; + default: + return undefined; + } +}; + +const formatUsd = (val: number): string => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(val); + +const formatHealthFactor = (factor: number): string => { + if (factor === Infinity) return '∞'; + return factor.toFixed(2); +}; + export default async function DashboardPage() { - const data = await fetchDashboardData(); - const { portfolio, recentActivity } = data; - - const getActionBadgeClassName = (action: string) => { - switch (action) { - case 'DEPOSIT': - return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400'; - case 'BORROW': - return 'border-amber-500/20 bg-amber-500/10 text-amber-400'; - case 'REPAY': - return 'border-purple-500/20 bg-purple-500/10 text-purple-400'; - case 'WITHDRAW': - return 'border-cyan-500/20 bg-cyan-500/10 text-cyan-400'; - default: - return undefined; - } + const walletAddress = await getWalletAddress(); + + // Redirect to home if no wallet is connected + if (!walletAddress) { + redirect('/'); + } + + let data; + let error: string | null = null; + + try { + data = await fetchDashboardData(walletAddress); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load dashboard data'; + data = null; + } + + const { portfolio, recentActivity } = data || { + portfolio: { + totalBalanceUsd: 0, + healthFactor: Infinity, + totalDepositedUsd: 0, + totalBorrowedUsd: 0, + depositedAssets: [], + borrowedAssets: [], + lastUpdated: new Date().toISOString(), + }, + recentActivity: [], }; - const formatUsd = (val: number) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val); + // Handle error state + if (error) { + return ( +
+ + + + {error} + + + +
+ ); + } return (
@@ -36,6 +106,12 @@ export default async function DashboardPage() {

Live Dashboard

Overview of your Stellar network portfolio and recent activity. + + Wallet: {walletAddress.slice(0, 6)}...{walletAddress.slice(-6)} + +

+

+ Last updated: {new Date(portfolio.lastUpdated).toLocaleString()}

@@ -49,7 +125,7 @@ export default async function DashboardPage() { Total Balance -
+
= 0 ? 'text-text' : 'text-error'}`}> {formatUsd(portfolio.totalBalanceUsd)}
@@ -71,10 +147,12 @@ export default async function DashboardPage() { Total Borrowed = 1.1 || portfolio.healthFactor === Infinity + ? 'bg-emerald-500/10 text-emerald-400' + : undefined} > - Health: {portfolio.healthFactor.toFixed(2)} + Health: {formatHealthFactor(portfolio.healthFactor)} @@ -96,7 +174,7 @@ export default async function DashboardPage() { {portfolio.depositedAssets.length === 0 ? ( -

No deposited assets.

+

No deposited assets found.

) : ( {portfolio.depositedAssets.map((asset) => ( @@ -113,7 +191,7 @@ export default async function DashboardPage() {
{asset.assetName}
- {asset.balance.toLocaleString()} {asset.assetSymbol} + {asset.balance.toLocaleString(undefined, { maximumFractionDigits: 4 })} {asset.assetSymbol}
@@ -148,7 +226,7 @@ export default async function DashboardPage() {
{asset.assetName}
- {asset.balance.toLocaleString()} {asset.assetSymbol} + {asset.balance.toLocaleString(undefined, { maximumFractionDigits: 4 })} {asset.assetSymbol}
@@ -173,7 +251,7 @@ export default async function DashboardPage() {

No recent activity found.

) : ( - {recentActivity.map((activity) => ( + {recentActivity.slice(0, 20).map((activity) => ( - {activity.amount.toLocaleString()} {activity.assetSymbol} + {activity.amount.toLocaleString(undefined, { maximumFractionDigits: 4 })} {activity.assetSymbol}
@@ -213,4 +291,4 @@ export default async function DashboardPage() {
); -} +} \ No newline at end of file diff --git a/veilend-web/src/app/page.tsx b/veilend-web/src/app/page.tsx index 80f60fc..6c4e21f 100644 --- a/veilend-web/src/app/page.tsx +++ b/veilend-web/src/app/page.tsx @@ -18,9 +18,10 @@ import { Badge } from "@/components/ui/badge" import { useWallet } from "@/context/WalletContext" export default function VeilLendLandingPage() { - const { isConnected, isAuthenticated } = useWallet(); + const { isConnected, isAuthenticated, isLoading, address, error } = useWallet(); // Simulated Live Campaign Metrics for the contributor block const [totalContributed, setTotalContributed] = React.useState(642850) + const [connectionError, setConnectionError] = React.useState(null) React.useEffect(() => { const interval = setInterval(() => { @@ -29,6 +30,38 @@ export default function VeilLendLandingPage() { return () => clearInterval(interval) }, []) + // Handle wallet errors - using useCallback to avoid setState in effect + React.useEffect(() => { + if (error) { + // Use a timeout to avoid synchronous setState in effect + const timer = setTimeout(() => { + setConnectionError(error) + }, 0) + + const clearTimer = setTimeout(() => { + setConnectionError(null) + }, 5000) + + return () => { + clearTimeout(timer) + clearTimeout(clearTimer) + } + } + }, [error]) + + const getConnectButtonText = () => { + if (isLoading) return "Connecting..." + if (isConnected && isAuthenticated) return "Go to Dashboard" + if (connectionError) return "Retry Connection" + return "Connect Wallet to Start" + } + + const getConnectButtonIcon = () => { + if (isLoading) return null + if (isConnected && isAuthenticated) return + return + } + return (
@@ -55,20 +88,52 @@ export default function VeilLendLandingPage() {

+ {/* Connection status display */} + {isConnected && isAuthenticated && address && ( +
+ + Connected: {address.slice(0, 6)}...{address.slice(-4)} +
+ )} + + {/* Primary CTA Button */} {isConnected && isAuthenticated ? ( - ) : ( - )} +
+ {/* Error display */} + {connectionError && ( +
+

{connectionError}

+
+ )} + {/* Dynamic Abstract Code Terminal Frame Mockup */}
@@ -233,11 +298,31 @@ export default function VeilLendLandingPage() { Go to Dashboard ) : ( - )}
+ {/* Connected status display for CTA section */} + {isConnected && isAuthenticated && address && ( +
+ + Wallet connected: {address.slice(0, 6)}...{address.slice(-4)} +
+ )} + {connectionError && ( +
+

{connectionError}

+
+ )}
diff --git a/veilend-web/src/components/Layout.tsx b/veilend-web/src/components/Layout.tsx index 41870b3..b14dc5e 100644 --- a/veilend-web/src/components/Layout.tsx +++ b/veilend-web/src/components/Layout.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { cn } from "@/lib/utils"; interface ContainerProps { children: React.ReactNode; @@ -10,7 +11,7 @@ export const Container: React.FC = ({ className = "", }) => { return ( -
+
{children}
); @@ -19,19 +20,32 @@ export const Container: React.FC = ({ interface SectionProps { children: React.ReactNode; className?: string; + padding?: "none" | "sm" | "md" | "lg"; } export const Section: React.FC = ({ children, className = "", + padding = "md", }) => { - return
{children}
; + const paddingClasses: Record = { + none: "py-0", + sm: "py-6", + md: "py-12", + lg: "py-20", + }; + + return ( +
+ {children} +
+ ); }; interface FlexProps { children: React.ReactNode; direction?: "row" | "col"; - justify?: "start" | "center" | "end" | "between" | "around"; + justify?: "start" | "center" | "end" | "between" | "around" | "evenly"; align?: "start" | "center" | "end" | "stretch"; gap?: "none" | "sm" | "md" | "lg" | "xl"; wrap?: boolean; @@ -58,6 +72,7 @@ export const Flex: React.FC = ({ end: "justify-end", between: "justify-between", around: "justify-around", + evenly: "justify-evenly", }; const alignClasses: Record = { @@ -77,7 +92,15 @@ export const Flex: React.FC = ({ return (
{children}
@@ -86,7 +109,7 @@ export const Flex: React.FC = ({ interface GridProps { children: React.ReactNode; - columns?: number; + columns?: number | { sm?: number; md?: number; lg?: number; xl?: number }; gap?: "none" | "sm" | "md" | "lg" | "xl"; className?: string; } @@ -105,18 +128,91 @@ export const Grid: React.FC = ({ xl: "gap-8", }; - const columnClasses: Record = { - 1: "grid-cols-1", - 2: "grid-cols-1 md:grid-cols-2", - 3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3", - 4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4", + const getColumnsClass = () => { + if (typeof columns === "number") { + const colMap: Record = { + 1: "grid-cols-1", + 2: "grid-cols-1 md:grid-cols-2", + 3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3", + 4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4", + 5: "grid-cols-1 md:grid-cols-3 lg:grid-cols-5", + 6: "grid-cols-1 md:grid-cols-3 lg:grid-cols-6", + }; + return colMap[columns] || `grid-cols-${columns}`; + } + + const parts: string[] = []; + if (columns.sm) parts.push(`grid-cols-${columns.sm}`); + if (columns.md) parts.push(`md:grid-cols-${columns.md}`); + if (columns.lg) parts.push(`lg:grid-cols-${columns.lg}`); + if (columns.xl) parts.push(`xl:grid-cols-${columns.xl}`); + + if (parts.length === 0) return "grid-cols-1"; + + return parts.join(" "); }; return (
{children}
); }; + +interface GridResponsiveProps { + children: React.ReactNode; + columns?: { sm?: number; md?: number; lg?: number; xl?: number }; + gap?: "none" | "sm" | "md" | "lg" | "xl"; + className?: string; +} + +export const GridResponsive: React.FC = ({ + children, + columns = { sm: 1, md: 2, lg: 3 }, + gap = "md", + className = "", +}) => { + const gapClasses: Record = { + none: "gap-0", + sm: "gap-2", + md: "gap-4", + lg: "gap-6", + xl: "gap-8", + }; + + const parts: string[] = []; + if (columns.sm) parts.push(`grid-cols-${columns.sm}`); + if (columns.md) parts.push(`md:grid-cols-${columns.md}`); + if (columns.lg) parts.push(`lg:grid-cols-${columns.lg}`); + if (columns.xl) parts.push(`xl:grid-cols-${columns.xl}`); + + return ( +
+ {children} +
+ ); +}; + +const LayoutComponents = { + Container, + Section, + Flex, + Grid, + GridResponsive, +}; + +export default LayoutComponents; diff --git a/veilend-web/src/components/WalletConnect.tsx b/veilend-web/src/components/WalletConnect.tsx index 1751ddc..790f3f5 100644 --- a/veilend-web/src/components/WalletConnect.tsx +++ b/veilend-web/src/components/WalletConnect.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { Wallet, X, ExternalLink } from "lucide-react"; +import { Wallet, X, ExternalLink, Loader2, AlertCircle, CheckCircle2, Download } from "lucide-react"; import { useWallet } from "@/context/WalletContext"; import { Button } from "@/components/ui/button"; import { @@ -13,25 +13,39 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +// import { Flex } from "@/components/Layout"; import { getWalletConnectionMessage, isFreighterInstalled } from "@/lib/stellar/wallet"; -// Define the Freighter window interface -interface FreighterWindow extends Window { - freighter?: { - isConnected: () => Promise<{ isConnected: boolean }>; - getAddress: () => Promise<{ address: string }>; - signTransaction: (txXdr: string, opts?: { networkPassphrase?: string }) => Promise<{ signedTxXdr: string }>; - }; -} - interface WalletConnectProps { + variant?: 'default' | 'compact' | 'full'; className?: string; size?: "sm" | "default" | "lg"; + onSuccess?: (address: string) => void; + onError?: (error: Error) => void; } -export function WalletConnect({ className, size = "default" }: WalletConnectProps) { - const { address, isConnected, isAuthenticated, isLoading, error, connect, disconnect, clearError } = useWallet(); +export function WalletConnect({ + variant = 'default', + className = "", + size = "default", + onSuccess, + onError +}: WalletConnectProps) { + const { + address, + isConnected, + isAuthenticated, + isInstalled, + isLoading, + error, + connect, + disconnect, + clearError + } = useWallet(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); const primaryActionRef = useRef(null); const handleOpenChange = (nextOpen: boolean) => { @@ -42,20 +56,47 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp }; const handleConnect = async () => { - const success = await connect(); - if (success) { - setIsModalOpen(false); + if (isConnecting || isLoading) return; + + try { + setIsConnecting(true); + clearError(); + + const success = await connect(); + + if (success && address && onSuccess) { + onSuccess(address); + } + + if (success) { + setIsModalOpen(false); + } + } catch (err) { + const errorObj = err instanceof Error ? err : new Error('Failed to connect wallet'); + if (onError) { + onError(errorObj); + } + } finally { + setIsConnecting(false); } }; const handleDisconnect = async () => { - await disconnect(); - setIsModalOpen(false); - clearError(); + try { + await disconnect(); + setIsModalOpen(false); + clearError(); + } catch (err) { + console.error('Disconnect error:', err); + } }; const walletMessage = getWalletConnectionMessage(error, isFreighterInstalled()); + const truncatedAddress = address + ? `${address.slice(0, 6)}...${address.slice(-4)}` + : ""; + // Focus management useEffect(() => { if (!isModalOpen) return; @@ -66,12 +107,99 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp return () => window.clearTimeout(focusTimer); }, [isModalOpen, error]); - const truncatedAddress = address - ? `${address.slice(0, 6)}...${address.slice(-4)}` - : ""; + // Compact variant - minimal display + if (variant === 'compact') { + if (isConnected && isAuthenticated && address) { + return ( +
+
+ + + {address.slice(0, 6)}...{address.slice(-4)} + +
+ +
+ ); + } + + return ( + + ); + } - // Wallet is connected - if (isConnected && isAuthenticated) { + // Full variant - with extra description + if (variant === 'full') { + return ( +
+ {isConnected && isAuthenticated && address ? ( +
+
+
+ +
+
+
Connected
+
+ {address.slice(0, 6)}...{address.slice(-6)} +
+
+
+ +
+ ) : ( + <> + +

+ Connect your Stellar wallet to access the dashboard +

+ + )} +
+ ); + } + + // Default variant - connected state + if (isConnected && isAuthenticated && address) { return (
@@ -90,18 +218,22 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp ); } - // Wallet not connected + // Default variant - disconnected state return ( <> @@ -117,6 +249,16 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp
+ {/* Error state inside modal */} + {error && ( + + + + {error} + + + )} + {/* Freighter Wallet Option */}
@@ -133,16 +275,29 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp variant="default" size="sm" onClick={handleConnect} - disabled={isLoading} - className="bg-emerald-600 hover:bg-emerald-500" + disabled={isLoading || isConnecting || !isInstalled} + className="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50" > - {isLoading ? "Connecting..." : error ? walletMessage.primaryAction : "Connect"} + {isLoading || isConnecting ? ( + + ) : error ? ( + "Retry" + ) : isInstalled ? ( + "Connect" + ) : ( + "Install" + )}
- {(error || !isFreighterInstalled()) && ( + {/* Wallet status messages */} + {(!isInstalled || error) && (
@@ -157,30 +312,33 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp )} {/* Recovery actions */} - {(error || !isFreighterInstalled()) && ( + {(error || !isInstalled) && (
- + {!isInstalled && ( + + )}
)} - {/* Info Message */} - {!isFreighterInstalled() && !error && ( + {/* Install prompt */} + {!isInstalled && !error && (

Freighter wallet not detected. @@ -217,5 +375,4 @@ export function WalletConnect({ className, size = "default" }: WalletConnectProp ); } - export default WalletConnect; \ No newline at end of file diff --git a/veilend-web/src/components/WalletStatus.tsx b/veilend-web/src/components/WalletStatus.tsx index 3efa241..ddc36e6 100644 --- a/veilend-web/src/components/WalletStatus.tsx +++ b/veilend-web/src/components/WalletStatus.tsx @@ -1,37 +1,114 @@ "use client"; import React from "react"; -import { Wallet, ShieldCheck, ShieldAlert, Loader2 } from "lucide-react"; +import { Wallet, ShieldCheck, ShieldAlert, Loader2, XCircle } from "lucide-react"; import { useWallet } from "@/context/WalletContext"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface WalletStatusProps { showDetails?: boolean; className?: string; + onConnect?: () => void; + onDisconnect?: () => void; } -export function WalletStatus({ showDetails = false, className = "" }: WalletStatusProps) { - const { address, isConnected, isAuthenticated, isLoading, error } = useWallet(); +export function WalletStatus({ + showDetails = false, + className = "", + onConnect, + onDisconnect +}: WalletStatusProps) { + const { + address, + isConnected, + isAuthenticated, + isInstalled, + isLoading, + error, + connect, + disconnect, + clearError + } = useWallet(); + const handleConnect = async () => { + if (onConnect) { + onConnect(); + } + await connect(); + }; + + const handleDisconnect = async () => { + if (onDisconnect) { + onDisconnect(); + } + await disconnect(); + }; + + // Loading state if (isLoading) { return (

- Initializing wallet... + Connecting...
); } + // Error state with tooltip if (error) { return ( -
- - Wallet error - {showDetails && {error}} +
+ + + Error + + + + + + {error} + + + +

{error}

+ +
+
+
+
+ ); + } + + // Freighter not installed + if (!isInstalled) { + return ( +
+ + + Freighter Required + +
); } + // Connected state if (isConnected && isAuthenticated) { const truncatedAddress = address ? `${address.slice(0, 6)}...${address.slice(-4)}` @@ -43,17 +120,34 @@ export function WalletStatus({ showDetails = false, className = "" }: WalletStat Connected - {showDetails && ( + {showDetails && address && ( {truncatedAddress} )} +
); } + // Disconnected state return ( -
- - Not connected +
+ + Not connected +
); } diff --git a/veilend-web/src/components/ui/tooltip.tsx b/veilend-web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..a8be005 --- /dev/null +++ b/veilend-web/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } \ No newline at end of file diff --git a/veilend-web/src/lib/api/dashboard-client.ts b/veilend-web/src/lib/api/dashboard-client.ts new file mode 100644 index 0000000..35cc331 --- /dev/null +++ b/veilend-web/src/lib/api/dashboard-client.ts @@ -0,0 +1,236 @@ +import { DashboardData, AssetBalance, ActivityEvent, ActivityActionType } from '../types/dashboard'; + +export interface DashboardClientConfig { + apiBaseUrl: string; + refreshInterval?: number; +} + +export class DashboardClient { + private apiBaseUrl: string; + private refreshInterval: number; + private abortController: AbortController | null = null; + + constructor(config: DashboardClientConfig) { + this.apiBaseUrl = config.apiBaseUrl; + this.refreshInterval = config.refreshInterval || 10000; + } + + async fetchDashboardData(address: string, signal?: AbortSignal): Promise { + if (!address || !address.startsWith('G')) { + throw new Error('Invalid Stellar address provided'); + } + + try { + const controller = new AbortController(); + const combinedSignal = signal ? this.combineSignals(signal, controller.signal) : controller.signal; + + const [positionsRes, transactionsRes, pricesRes] = await Promise.all([ + fetch(`${this.apiBaseUrl}/indexer/positions/${address}`, { + signal: combinedSignal, + headers: { 'Cache-Control': 'no-cache' } + }), + fetch(`${this.apiBaseUrl}/indexer/transactions/${address}`, { + signal: combinedSignal, + headers: { 'Cache-Control': 'no-cache' } + }), + fetch(`${this.apiBaseUrl}/oracle/prices`, { + signal: combinedSignal, + headers: { 'Cache-Control': 'no-cache' } + }) + ]); + + if (!positionsRes.ok || !transactionsRes.ok || !pricesRes.ok) { + const errorDetails = { + positions: positionsRes.status, + transactions: transactionsRes.status, + prices: pricesRes.status + }; + throw new Error(`Failed to fetch data from indexer: ${JSON.stringify(errorDetails)}`); + } + + const [positionsData, transactionsData, pricesData] = await Promise.all([ + positionsRes.json(), + transactionsRes.json(), + pricesRes.json() + ]); + + return this.transformDashboardData( + positionsData.positions || [], + transactionsData.transactions || [], + pricesData.prices || {} + ); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error; + } + console.error('Dashboard API Error:', error); + throw new Error('Failed to fetch live dashboard data. Please check your connection.'); + } + } + + private combineSignals(signal1: AbortSignal, signal2: AbortSignal): AbortSignal { + const controller = new AbortController(); + const onAbort = () => controller.abort(); + + if (signal1.aborted) { + controller.abort(); + return controller.signal; + } + if (signal2.aborted) { + controller.abort(); + return controller.signal; + } + + signal1.addEventListener('abort', onAbort, { once: true }); + signal2.addEventListener('abort', onAbort, { once: true }); + + return controller.signal; + } + + private transformDashboardData( + positions: Array<{ assetAddress: string; depositedAmount: string | number; borrowedAmount: string | number }>, + transactions: Array<{ id: string; type: string; amount: string | number; assetAddress: string; timestamp: string; txHash: string }>, + prices: Record + ): DashboardData { + let totalDepositedUsd = 0; + let totalBorrowedUsd = 0; + const depositedAssets: AssetBalance[] = []; + const borrowedAssets: AssetBalance[] = []; + + // Process positions with real oracle prices + if (Array.isArray(positions) && positions.length > 0) { + positions.forEach((pos) => { + const deposited = Number(pos.depositedAmount) / 1e7; + const borrowed = Number(pos.borrowedAmount) / 1e7; + + // Get price from oracle, default to 0 if not available + const price = prices[pos.assetAddress] || 0; + + if (deposited > 0 && price > 0) { + const usdValue = deposited * price; + totalDepositedUsd += usdValue; + depositedAssets.push({ + assetSymbol: this.extractAssetSymbol(pos.assetAddress), + assetName: pos.assetAddress, + balance: deposited, + usdValue, + }); + } + + if (borrowed > 0 && price > 0) { + const usdValue = borrowed * price; + totalBorrowedUsd += usdValue; + borrowedAssets.push({ + assetSymbol: this.extractAssetSymbol(pos.assetAddress), + assetName: pos.assetAddress, + balance: borrowed, + usdValue, + }); + } + }); + } + + // Calculate health factor with proper LTV + const healthFactor = totalBorrowedUsd === 0 + ? Infinity + : Math.min((totalDepositedUsd * 0.8) / totalBorrowedUsd, 99.99); + + const totalBalanceUsd = totalDepositedUsd - totalBorrowedUsd; + + // Process transactions with real prices + let recentActivity: ActivityEvent[] = []; + + if (Array.isArray(transactions) && transactions.length > 0) { + const mappedActivities = transactions.map((tx) => { + const amount = Number(tx.amount) / 1e7; + const price = prices[tx.assetAddress] || 0; + const usdValue = amount * price; + + // Skip transactions with no value + if (usdValue <= 0) { + return null; + } + + // Create activity with proper status type + const activity: ActivityEvent = { + id: tx.id, + action: this.mapTransactionType(tx.type), + assetSymbol: this.extractAssetSymbol(tx.assetAddress), + amount, + usdValue, + timestamp: tx.timestamp, + status: 'COMPLETED', // All indexer transactions are completed + txHash: tx.txHash, + }; + + return activity; + }); + + // Filter out null values and assert the type + recentActivity = mappedActivities + .filter((activity): activity is ActivityEvent => activity !== null) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 50); + } + + return { + portfolio: { + totalBalanceUsd, + healthFactor, + totalDepositedUsd, + totalBorrowedUsd, + depositedAssets, + borrowedAssets, + lastUpdated: new Date().toISOString(), + }, + recentActivity, + }; + } + + private extractAssetSymbol(assetAddress: string): string { + // Extract asset symbol from address + if (assetAddress.includes('USDC')) return 'USDC'; + if (assetAddress.includes('XLM')) return 'XLM'; + if (assetAddress.includes('BTC')) return 'BTC'; + if (assetAddress.includes('ETH')) return 'ETH'; + return assetAddress.slice(-4); + } + + private mapTransactionType(type: string): ActivityActionType { + const normalizedType = type.toUpperCase(); + switch (normalizedType) { + case 'DEPOSIT': + case 'SUPPLY': + return 'DEPOSIT'; + case 'BORROW': + return 'BORROW'; + case 'REPAY': + case 'REPAYMENT': + return 'REPAY'; + case 'WITHDRAW': + case 'WITHDRAWAL': + return 'WITHDRAW'; + default: + console.warn(`Unknown transaction type: ${type}, defaulting to DEPOSIT`); + return 'DEPOSIT'; + } + } + + destroy(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } +} + +// Singleton instance +let dashboardClientInstance: DashboardClient | null = null; + +export function getDashboardClient(): DashboardClient { + if (!dashboardClientInstance) { + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + dashboardClientInstance = new DashboardClient({ apiBaseUrl }); + } + return dashboardClientInstance; +} \ No newline at end of file diff --git a/veilend-web/src/lib/api/dashboard.ts b/veilend-web/src/lib/api/dashboard.ts index accdd26..f0b5da5 100644 --- a/veilend-web/src/lib/api/dashboard.ts +++ b/veilend-web/src/lib/api/dashboard.ts @@ -2,10 +2,6 @@ import { DashboardData, ActivityActionType, AssetBalance } from '../types/dashbo const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -// For the demo/campaign, we can use a hardcoded or random dummy address if none is provided. -// In a real app with wallet connect, this would be passed dynamically. -const DEMO_ADDRESS = 'GCOQ4Z...'; // example stellar pubkey - // Define types for API responses interface IndexerPosition { assetAddress: string; @@ -22,77 +18,124 @@ interface IndexerTransaction { txHash: string; } -export async function fetchDashboardData(address: string = DEMO_ADDRESS): Promise { +interface OraclePrice { + [assetAddress: string]: number; +} + +/** + * Fetches live dashboard data for a specific wallet address + * @param address - Stellar wallet address (must start with 'G') + * @returns Promise + * @throws Error if address is invalid or API calls fail + */ +export async function fetchDashboardData(address: string): Promise { + // Validate address + if (!address || !address.startsWith('G')) { + throw new Error('Invalid Stellar address provided. Address must start with "G".'); + } + try { - const [positionsRes, transactionsRes] = await Promise.all([ - fetch(`${API_BASE_URL}/indexer/positions/${address}`, { next: { revalidate: 10 } }), - fetch(`${API_BASE_URL}/indexer/transactions/${address}`, { next: { revalidate: 10 } }) + const [positionsRes, transactionsRes, pricesRes] = await Promise.all([ + fetch(`${API_BASE_URL}/indexer/positions/${address}`, { + next: { revalidate: 10 }, + headers: { 'Cache-Control': 'no-cache' } + }), + fetch(`${API_BASE_URL}/indexer/transactions/${address}`, { + next: { revalidate: 10 }, + headers: { 'Cache-Control': 'no-cache' } + }), + fetch(`${API_BASE_URL}/oracle/prices`, { + next: { revalidate: 10 }, + headers: { 'Cache-Control': 'no-cache' } + }) ]); - if (!positionsRes.ok || !transactionsRes.ok) { - throw new Error('Failed to fetch data from live indexer endpoints'); + if (!positionsRes.ok || !transactionsRes.ok || !pricesRes.ok) { + const errorDetails = { + positions: positionsRes.status, + transactions: transactionsRes.status, + prices: pricesRes.status + }; + throw new Error(`Failed to fetch data from indexer: ${JSON.stringify(errorDetails)}`); } - const { positions } = await positionsRes.json() as { positions: IndexerPosition[] }; - const { transactions } = await transactionsRes.json() as { transactions: IndexerTransaction[] }; + const positionsData = await positionsRes.json() as { positions: IndexerPosition[] }; + const transactionsData = await transactionsRes.json() as { transactions: IndexerTransaction[] }; + const pricesData = await pricesRes.json() as { prices: OraclePrice }; + + // Validate response structure + if (!positionsData.positions || !Array.isArray(positionsData.positions)) { + throw new Error('Invalid positions data format received from indexer'); + } let totalDepositedUsd = 0; let totalBorrowedUsd = 0; const depositedAssets: AssetBalance[] = []; const borrowedAssets: AssetBalance[] = []; - // Map positions (assuming naive 1:1 USD pricing for demo unless oracle is integrated) - // The backend returns IndexerPosition: { assetAddress, depositedAmount, borrowedAmount } - if (Array.isArray(positions)) { - positions.forEach((pos: IndexerPosition) => { - const deposited = Number(pos.depositedAmount) / 1e7; // Assuming 7 decimals (Stellar standard) - const borrowed = Number(pos.borrowedAmount) / 1e7; - - // Mock price for demo since Oracle might not be fully wired in the read models yet - const price = pos.assetAddress.includes('USDC') ? 1.0 : 0.11; // Dummy XLM price - - if (deposited > 0) { - const usdValue = deposited * price; - totalDepositedUsd += usdValue; - depositedAssets.push({ - assetSymbol: pos.assetAddress.substring(0, 4), // Dummy symbol from address - assetName: pos.assetAddress, - balance: deposited, - usdValue, - }); - } - - if (borrowed > 0) { - const usdValue = borrowed * price; - totalBorrowedUsd += usdValue; - borrowedAssets.push({ - assetSymbol: pos.assetAddress.substring(0, 4), - assetName: pos.assetAddress, - balance: borrowed, - usdValue, - }); - } - }); - } + // Map positions with real oracle prices + const positions = positionsData.positions || []; + const transactions = transactionsData.transactions || []; + const prices = pricesData.prices || {}; + + positions.forEach((pos: IndexerPosition) => { + const deposited = Number(pos.depositedAmount) / 1e7; + const borrowed = Number(pos.borrowedAmount) / 1e7; + + // Use oracle price, fallback to 0 if not available + const price = prices[pos.assetAddress] || 0; - const healthFactor = totalBorrowedUsd === 0 ? 99.99 : (totalDepositedUsd * 0.8) / totalBorrowedUsd; // Assumed 80% LTV + if (deposited > 0 && price > 0) { + const usdValue = deposited * price; + totalDepositedUsd += usdValue; + depositedAssets.push({ + assetSymbol: extractAssetSymbol(pos.assetAddress), + assetName: pos.assetAddress, + balance: deposited, + usdValue, + }); + } + + if (borrowed > 0 && price > 0) { + const usdValue = borrowed * price; + totalBorrowedUsd += usdValue; + borrowedAssets.push({ + assetSymbol: extractAssetSymbol(pos.assetAddress), + assetName: pos.assetAddress, + balance: borrowed, + usdValue, + }); + } + }); + + // Calculate health factor with proper LTV + const healthFactor = totalBorrowedUsd === 0 + ? Infinity + : Math.min((totalDepositedUsd * 0.8) / totalBorrowedUsd, 99.99); + const totalBalanceUsd = totalDepositedUsd - totalBorrowedUsd; - // Map transactions - const recentActivity = Array.isArray(transactions) ? transactions.map((tx: IndexerTransaction) => { - const amount = Number(tx.amount) / 1e7; - const price = tx.assetAddress.includes('USDC') ? 1.0 : 0.11; - return { - id: tx.id, - action: tx.type.toUpperCase() as ActivityActionType, - assetSymbol: tx.assetAddress.substring(0, 4), - amount, - usdValue: amount * price, - timestamp: tx.timestamp, - status: 'COMPLETED' as const, - txHash: tx.txHash, - }; - }).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) : []; + // Map transactions with real prices + const recentActivity = Array.isArray(transactions) + ? transactions + .map((tx: IndexerTransaction) => { + const amount = Number(tx.amount) / 1e7; + const price = prices[tx.assetAddress] || 0; + return { + id: tx.id, + action: mapTransactionType(tx.type), + assetSymbol: extractAssetSymbol(tx.assetAddress), + amount, + usdValue: amount * price, + timestamp: tx.timestamp, + status: 'COMPLETED' as const, + txHash: tx.txHash, + }; + }) + .filter((activity) => activity.usdValue > 0) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 50) + : []; return { portfolio: { @@ -107,7 +150,52 @@ export async function fetchDashboardData(address: string = DEMO_ADDRESS): Promis recentActivity, }; } catch (error) { - console.error('API Error:', error); - throw new Error('Could not load live dashboard data. Is the NestJS indexer running?'); + console.error('Dashboard API Error:', error); + + // Re-throw with user-friendly message + if (error instanceof Error) { + throw new Error(`Failed to load dashboard data: ${error.message}`); + } + throw new Error('Could not load live dashboard data. Please check your connection.'); + } +} + +/** + * Extracts a short asset symbol from the asset address + * @param assetAddress - Full asset address + * @returns 4-character symbol + */ +function extractAssetSymbol(assetAddress: string): string { + // For production, this should use a proper mapping from the backend + // or extract from the asset code if available + if (assetAddress.includes('USDC')) return 'USDC'; + if (assetAddress.includes('XLM')) return 'XLM'; + if (assetAddress.includes('BTC')) return 'BTC'; + if (assetAddress.includes('ETH')) return 'ETH'; + return assetAddress.slice(-4); +} + +/** + * Maps transaction type to ActivityActionType + * @param type - Raw transaction type from indexer + * @returns Normalized ActivityActionType + */ +function mapTransactionType(type: string): ActivityActionType { + const normalizedType = type.toUpperCase(); + switch (normalizedType) { + case 'DEPOSIT': + case 'SUPPLY': + return 'DEPOSIT'; + case 'BORROW': + return 'BORROW'; + case 'REPAY': + case 'REPAYMENT': + return 'REPAY'; + case 'WITHDRAW': + case 'WITHDRAWAL': + return 'WITHDRAW'; + default: + console.warn(`Unknown transaction type: ${type}, defaulting to DEPOSIT`); + return 'DEPOSIT'; } } \ No newline at end of file diff --git a/veilend-web/src/lib/hooks/useDashboardData.ts b/veilend-web/src/lib/hooks/useDashboardData.ts new file mode 100644 index 0000000..7aa5969 --- /dev/null +++ b/veilend-web/src/lib/hooks/useDashboardData.ts @@ -0,0 +1,196 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { DashboardData } from '../types/dashboard'; +import { getDashboardClient } from '../api/dashboard-client'; + +export interface UseDashboardDataOptions { + address: string | null; + enabled?: boolean; + refreshInterval?: number; + retryOnError?: boolean; + maxRetries?: number; +} + +export interface UseDashboardDataResult { + data: DashboardData | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + isStale: boolean; + refetch: () => Promise; + lastUpdated: Date | null; +} + +export function useDashboardData(options: UseDashboardDataOptions): UseDashboardDataResult { + const { + address, + enabled = true, + refreshInterval = 30000, + retryOnError = true, + maxRetries = 3, + } = options; + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + const [error, setError] = useState(null); + const [isStale, setIsStale] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); + + const retryCount = useRef(0); + const retryTimeoutRef = useRef(null); + const intervalIdRef = useRef(null); + const abortControllerRef = useRef(null); + + // Ref container to hold fetchData reference for safe recursive retry calls + const fetchDataRef = useRef<() => Promise>(() => Promise.resolve()); + + const client = getDashboardClient(); + + // Clear pending timers & abort pending requests + const cleanupPendingOperations = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + // Primary fetch execution function + const fetchData = useCallback(async (): Promise => { + // Validate address format + if (!address || !address.startsWith('G')) { + setData(null); + setIsLoading(false); + setIsError(true); + setError(new Error('Invalid wallet address')); + return; + } + + cleanupPendingOperations(); + abortControllerRef.current = new AbortController(); + + setIsLoading(true); + setIsError(false); + setError(null); + setIsStale(false); + + try { + const dashboardData = await client.fetchDashboardData( + address, + abortControllerRef.current.signal + ); + + setData(dashboardData); + setLastUpdated(new Date()); + retryCount.current = 0; + setIsStale(false); + setIsLoading(false); + } catch (err) { + // Ignore request cancellations + if (err instanceof Error && err.name === 'AbortError') { + return; + } + + const errorObj = err instanceof Error ? err : new Error('Failed to fetch dashboard data'); + setError(errorObj); + setIsError(true); + setIsLoading(false); + + // Exponential backoff retry logic using ref call to satisfy ESLint + if (retryOnError && retryCount.current < maxRetries) { + const backoffDelay = Math.min(1000 * Math.pow(2, retryCount.current), 10000); + retryCount.current += 1; + + retryTimeoutRef.current = setTimeout(() => { + if (enabled && address) { + fetchDataRef.current(); + } + }, backoffDelay); + } + } + }, [address, client, enabled, maxRetries, retryOnError, cleanupPendingOperations]); + + // Keep fetchDataRef in sync safely via effect + useEffect(() => { + fetchDataRef.current = fetchData; + }, [fetchData]); + + // Separate effect for clearing data when disabled or unmounting + useEffect(() => { + if (!enabled || !address) { + cleanupPendingOperations(); + const timer = setTimeout(() => { + setData(null); + setIsLoading(false); + setIsError(false); + setError(null); + }, 0); + + return () => clearTimeout(timer); + } + }, [enabled, address, cleanupPendingOperations]); + + // Main lifecycle effect: initial fetch + interval polling + useEffect(() => { + if (!enabled || !address) { + return; + } + + // Deferred execution prevents synchronous setState calls inside the effect + const initialTimer = setTimeout(() => { + fetchData(); + }, 0); + + if (refreshInterval > 0) { + intervalIdRef.current = setInterval(() => { + setIsStale(true); + fetchData(); + }, refreshInterval); + } + + return () => { + clearTimeout(initialTimer); + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + cleanupPendingOperations(); + }; + }, [address, enabled, refreshInterval, fetchData, cleanupPendingOperations]); + + // Manual refetch trigger + const refetch = useCallback(async (): Promise => { + if (!enabled || !address) return; + retryCount.current = 0; + await fetchData(); + }, [address, enabled, fetchData]); + + // Handle visibility changes when returning to active tab + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + setIsStale(true); + } else if (isStale && enabled && address) { + fetchData(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isStale, enabled, address, fetchData]); + + return { + data, + isLoading, + isError, + error, + isStale, + refetch, + lastUpdated, + }; +} diff --git a/veilend-web/src/lib/hooks/usePositionSync.ts b/veilend-web/src/lib/hooks/usePositionSync.ts index 4c7d473..9b6d872 100644 --- a/veilend-web/src/lib/hooks/usePositionSync.ts +++ b/veilend-web/src/lib/hooks/usePositionSync.ts @@ -1,4 +1,4 @@ -'use client' +'use client'; import * as React from 'react'; import { DashboardData } from '@/lib/types/dashboard'; @@ -29,9 +29,7 @@ interface UsePositionSyncOptions { /** * Keeps positions, collateral and borrowed values in sync with live protocol - * state by polling the indexer. Exposes explicit loading / empty / stale / error - * states so the dashboard can represent each clearly. Privacy mode is orthogonal: - * this hook only manages the values; masking is handled at render time. + * state by polling the indexer. */ export function usePositionSync( options: UsePositionSyncOptions = {}, @@ -48,20 +46,36 @@ export function usePositionSync( const [lastSyncedAt, setLastSyncedAt] = React.useState(null); const [error, setError] = React.useState(null); - // Guards against state updates after unmount / overlapping requests - const inFlight = React.useRef(false); - const mounted = React.useRef(true); + // Safely sync ref in useEffect to avoid react-hooks/refs render error + const dataRef = React.useRef(data); + React.useEffect(() => { + dataRef.current = data; + }, [data]); + + const inFlight = React.useRef(false); + const mounted = React.useRef(true); + const abortControllerRef = React.useRef(null); const load = React.useCallback(async () => { + if (!address || !address.startsWith('G')) { + setStatus('idle'); + setError('No valid wallet address provided'); + return; + } + if (inFlight.current) return; inFlight.current = true; - // Only show the full loading state on the very first fetch; subsequent - // polls refresh in the background without flickering the UI. + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + setStatus((prev) => (prev === 'idle' ? 'loading' : prev)); try { const result = await fetchDashboardData(address); + if (!mounted.current) return; const isEmpty = @@ -75,45 +89,102 @@ export function usePositionSync( setStatus(isEmpty ? 'empty' : 'live'); } catch (err) { if (!mounted.current) return; - setError(err instanceof Error ? err.message : 'Failed to sync positions.'); - // Keep stale data visible if we have it; otherwise surface the error. - setStatus(data ? 'stale' : 'error'); + + if (err instanceof Error && err.name === 'AbortError') { + return; + } + + const errorMessage = + err instanceof Error ? err.message : 'Failed to sync positions.'; + setError(errorMessage); + + setStatus(dataRef.current ? 'stale' : 'error'); } finally { inFlight.current = false; + abortControllerRef.current = null; } - }, [address, data]); + }, [address]); + + // Handle address change: Reset state cleanly + React.useEffect(() => { + const timer = setTimeout(() => { + if (address) { + setStatus('idle'); + setData(null); + setError(null); + setLastSyncedAt(null); + } + }, 0); + + return () => clearTimeout(timer); + }, [address]); + + const isValid = React.useMemo(() => { + return Boolean(enabled && address && address.startsWith('G')); + }, [enabled, address]); // Initial load + interval polling React.useEffect(() => { mounted.current = true; - if (!enabled) return; - // Intentional fetch-on-mount: load() flips status to 'loading' before the - // first await, which the rule flags; that initial transition is the point. - // eslint-disable-next-line react-hooks/set-state-in-effect - load(); - const id = setInterval(load, intervalMs); + if (!isValid) { + const timer = setTimeout(() => { + setStatus('idle'); + }, 0); + return () => clearTimeout(timer); + } + + const initialTimer = setTimeout(() => { + if (mounted.current) { + load(); + } + }, 0); + + const pollInterval = setInterval(() => { + if (mounted.current && isValid) { + load(); + } + }, intervalMs); + return () => { - mounted.current = false; - clearInterval(id); + clearTimeout(initialTimer); + clearInterval(pollInterval); + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, intervalMs, address]); + }, [isValid, intervalMs, load]); - // Independent staleness ticker: flags data as stale if a poll hasn't - // succeeded within staleAfterMs, even while requests keep failing. + // Staleness ticker: flags data as stale if poll hasn't succeeded within staleAfterMs React.useEffect(() => { if (lastSyncedAt === null) return; - const id = setInterval(() => { + + const ticker = setInterval(() => { if (!mounted.current) return; + setStatus((prev) => { if (prev !== 'live' && prev !== 'empty') return prev; - return Date.now() - lastSyncedAt > staleAfterMs ? 'stale' : prev; + + const isStale = Date.now() - lastSyncedAt > staleAfterMs; + return isStale ? 'stale' : prev; }); }, Math.min(staleAfterMs, 5_000)); - return () => clearInterval(id); + + return () => clearInterval(ticker); }, [lastSyncedAt, staleAfterMs]); + // Component unmount lifecycle + React.useEffect(() => { + return () => { + mounted.current = false; + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; + }, []); + return { status, data,