);
}
export function SkeletonLine({ className = "" }: { className?: string }) {
- return
+
+ Loading...
+
+ );
+}
+
+export function TableRowSkeleton({ cols = 5 }: { cols?: number }) {
+ return (
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+ ))}
);
}
+export function PoolCardSkeleton() {
+ return (
+
+ );
+}
+
+export function TransactionRowSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export function ChartSkeleton({ height = "h-48" }: { height?: string }) {
+ return (
+
+ Loading chart...
+
+ );
+}
+
+export function PageLoadingFallback({ message = "Loading..." }: { message?: string }) {
+ return (
+
+ );
+}
+
+export function LoadingTimeout({ message = "This is taking longer than expected..." }: { message?: string }) {
+ return (
+
+ {message}
+
+ );
+}
diff --git a/frontend/app/context/QueryProvider.tsx b/frontend/app/context/QueryProvider.tsx
new file mode 100644
index 000000000..0a1c1b40f
--- /dev/null
+++ b/frontend/app/context/QueryProvider.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import React from "react";
+import { QueryClient } from "@tanstack/react-query";
+import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
+import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000, // 30 seconds
+ gcTime: 5 * 60_000, // 5 minutes
+ refetchOnWindowFocus: true,
+ retry: 2,
+ },
+ },
+});
+
+// Only create persister on client side
+const persister =
+ typeof window !== "undefined"
+ ? createSyncStoragePersister({
+ storage: window.localStorage,
+ key: "nestera_query_cache",
+ })
+ : undefined;
+
+export function QueryProvider({ children }: { children: React.ReactNode }) {
+ if (!persister) {
+ // SSR fallback — no persistence
+ return (
+
{}, restoreClient: async () => undefined, removeClient: async () => {} } }}>
+ {children}
+
+ );
+ }
+
+ return (
+
+ query.state.status === "success",
+ },
+ }}
+ >
+ {children}
+
+ );
+}
+
+export { queryClient };
diff --git a/frontend/app/context/WalletContext.tsx b/frontend/app/context/WalletContext.tsx
index 34e36160d..cb843d25f 100644
--- a/frontend/app/context/WalletContext.tsx
+++ b/frontend/app/context/WalletContext.tsx
@@ -17,6 +17,7 @@ import {
} from "@stellar/freighter-api";
import { Horizon } from "@stellar/stellar-sdk";
import { env } from "../lib/env";
+import { queryClient } from "./QueryProvider";
import { usePrices, getAssetPrice } from "../hooks/usePrices";
interface Balance {
@@ -27,6 +28,8 @@ interface Balance {
usd_value: number;
}
+export type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "locked" | "error";
+
interface WalletState {
address: string | null;
network: string | null;
@@ -38,16 +41,25 @@ interface WalletState {
balances: Balance[];
totalUsdValue: number;
lastBalanceSync: number | null;
+ connectionStatus: ConnectionStatus;
}
interface WalletContextValue extends WalletState {
connect: () => Promise
;
disconnect: () => void;
+ reconnect: () => Promise;
fetchBalances: () => Promise;
}
const WalletContext = createContext(null);
+const COINGECKO_IDS: Record = {
+ XLM: "stellar",
+ USDC: "usd-coin",
+ AQUA: "aqua",
+};
+
+const STORAGE_KEY = "nestera_wallet_network";
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState({
address: null,
@@ -62,8 +74,26 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
lastBalanceSync: null,
});
+const INITIAL_STATE: WalletState = {
+ address: null,
+ network: null,
+ isConnected: false,
+ isLoading: false,
+ isBalancesLoading: false,
+ error: null,
+ balanceError: null,
+ balances: [],
+ totalUsdValue: 0,
+ lastBalanceSync: null,
+ connectionStatus: "idle",
+};
+
+export function WalletProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(INITIAL_STATE);
const refreshInterval = useRef(null);
const networkWatcher = useRef(null);
+ const disconnectCheckInterval = useRef(null);
+ const connectTimeoutRef = useRef(null);
// Use React Query for cached prices (updates every 5 minutes)
const { data: prices } = usePrices();
@@ -75,11 +105,47 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
};
const fetchBalances = useCallback(async () => {
- if (!state.address) return;
+ if (!state.address) {
+ try { queryClient.removeQueries({ queryKey: ["balances"] }); } catch {}
+ return;
+ }
setState((s) => ({ ...s, isBalancesLoading: true, balanceError: null }));
try {
+ const result = await queryClient.fetchQuery({
+ queryKey: ["balances", state.address],
+ queryFn: async () => {
+ const horizonUrl = getHorizonUrl(state.network);
+ const server = new Horizon.Server(horizonUrl);
+ const account = await server.loadAccount(state.address);
+
+ const assetIds = Object.values(COINGECKO_IDS).join(",");
+ const priceRes = await fetch(
+ `${env.coingeckoApi}/simple/price?ids=${assetIds}&vs_currencies=usd`
+ );
+ const prices = await priceRes.json();
+
+ let totalUsd = 0;
+ const balances: Balance[] = account.balances.map((b: any) => {
+ const code = b.asset_type === "native" ? "XLM" : b.asset_code;
+ const coingeckoId = COINGECKO_IDS[code];
+ const price = prices[coingeckoId]?.usd || (code === "USDC" ? 1 : 0);
+ const usdValue = parseFloat(b.balance) * price;
+ totalUsd += usdValue;
+ return {
+ asset_code: code,
+ balance: b.balance,
+ asset_type: b.asset_type,
+ asset_issuer: b.asset_issuer,
+ usd_value: usdValue,
+ };
+ });
+
+ return { balances, totalUsd };
+ },
+ staleTime: 30_000,
+ cacheTime: 300_000,
const horizonUrl = getHorizonUrl(state.network);
const server = new Horizon.Server(horizonUrl);
const account = await server.loadAccount(state.address);
@@ -102,50 +168,94 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
setState((s) => ({
...s,
- balances,
- totalUsdValue: totalUsd,
+ balances: result.balances,
+ totalUsdValue: result.totalUsd,
isBalancesLoading: false,
balanceError: null,
lastBalanceSync: Date.now(),
}));
} catch (err) {
- console.error("Failed to fetch balances:", err);
setState((s) => ({
...s,
isBalancesLoading: false,
- balanceError:
- err instanceof Error ? err.message : "Unable to refresh wallet balances.",
+ balanceError: err instanceof Error ? err.message : "Unable to refresh wallet balances.",
}));
}
}, [state.address, state.network, prices]);
// Restore session on mount
useEffect(() => {
+ const savedNetwork = typeof window !== "undefined"
+ ? localStorage.getItem(STORAGE_KEY)
+ : null;
+
(async () => {
try {
const connected = await isConnected();
if (connected?.isConnected) {
- const [addrResult, netResult] = await Promise.all([
- getAddress(),
- getNetwork(),
- ]);
+ const [addrResult, netResult] = await Promise.all([getAddress(), getNetwork()]);
if (addrResult?.address) {
+ const network = netResult?.network ?? savedNetwork ?? null;
+ if (network) localStorage.setItem(STORAGE_KEY, network);
setState((s) => ({
...s,
address: addrResult.address,
- network: netResult?.network ?? null,
+ network,
isConnected: true,
- isLoading: false,
- error: null,
+ connectionStatus: "connected",
}));
}
+ } else {
+ // Was previously connected but now disconnected
+ setState((s) => ({
+ ...s,
+ connectionStatus: savedNetwork ? "disconnected" : "idle",
+ }));
}
} catch {
- // Freighter not installed or not connected — silent fail
+ // Freighter not installed — silent fail
}
})();
}, []);
+ // Poll to detect wallet lock/disconnect from extension
+ useEffect(() => {
+ if (!state.isConnected) return;
+
+ disconnectCheckInterval.current = setInterval(async () => {
+ try {
+ const connected = await isConnected();
+ if (!connected?.isConnected) {
+ // Invalidate cached balances and mark as locked so UI prompts reconnect
+ try { queryClient.removeQueries({ queryKey: ["balances"] }); } catch {}
+ setState((s) => ({
+ ...s,
+ isConnected: false,
+ connectionStatus: "locked",
+ address: null,
+ balances: [],
+ totalUsdValue: 0,
+ isBalancesLoading: false,
+ lastBalanceSync: null,
+ }));
+ }
+ } catch {
+ // ignore
+ }
+ }, 5000);
+
+ return () => {
+ if (disconnectCheckInterval.current) {
+ clearInterval(disconnectCheckInterval.current);
+ disconnectCheckInterval.current = null;
+ }
+ };
+ }, [state.isConnected]);
+
+ // Fetch balances when address changes
+ useEffect(() => {
+ if (state.address) {
+ fetchBalances();
// Fetch balances when address changes (prices come from React Query cache)
useEffect(() => {
if (state.address) {
@@ -168,21 +278,16 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
lastBalanceSync: null,
}));
}
-
return () => {
if (refreshInterval.current) clearInterval(refreshInterval.current);
};
}, [state.address, fetchBalances]);
- // Watch for network changes when wallet is connected
+ // Watch for network changes
useEffect(() => {
if (!state.isConnected) {
if (networkWatcher.current) {
- try {
- networkWatcher.current.stop();
- } catch (error) {
- console.error("Error stopping network watcher:", error);
- }
+ try { networkWatcher.current.stop(); } catch {}
networkWatcher.current = null;
}
return;
@@ -190,83 +295,90 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
try {
networkWatcher.current = new WatchWalletChanges(3000);
-
networkWatcher.current.watch((changes) => {
if (changes.network && changes.network !== state.network) {
- setState((prevState) => ({
- ...prevState,
- network: changes.network,
- }));
+ localStorage.setItem(STORAGE_KEY, changes.network);
+ setState((s) => ({ ...s, network: changes.network }));
+ // trigger immediate refresh when network changes
+ fetchBalances();
}
});
- } catch (error) {
- console.error("Failed to initialize network watcher:", error);
- }
+ } catch {}
return () => {
if (networkWatcher.current) {
- try {
- networkWatcher.current.stop();
- } catch (error) {
- console.error("Error stopping network watcher:", error);
- }
+ try { networkWatcher.current.stop(); } catch {}
networkWatcher.current = null;
}
};
}, [state.isConnected, state.network]);
const connect = useCallback(async () => {
- setState((s) => ({ ...s, isLoading: true, error: null }));
+ setState((s) => ({ ...s, isLoading: true, error: null, connectionStatus: "connecting" }));
try {
+ // set a connection timeout to avoid hanging
+ if (connectTimeoutRef.current) clearTimeout(connectTimeoutRef.current);
+ connectTimeoutRef.current = setTimeout(() => {
+ setState((s) => ({ ...s, isLoading: false, connectionStatus: "error", error: "Connection timed out" }));
+ }, 15000);
const accessResult = await requestAccess();
if (accessResult?.error) {
+ if (connectTimeoutRef.current) {
+ clearTimeout(connectTimeoutRef.current);
+ connectTimeoutRef.current = null;
+ }
setState((s) => ({
...s,
isLoading: false,
error: accessResult.error ?? "Connection rejected",
+ connectionStatus: "error",
}));
return;
}
- const [addrResult, netResult] = await Promise.all([
- getAddress(),
- getNetwork(),
- ]);
+ const [addrResult, netResult] = await Promise.all([getAddress(), getNetwork()]);
+ if (connectTimeoutRef.current) {
+ clearTimeout(connectTimeoutRef.current);
+ connectTimeoutRef.current = null;
+ }
+ const network = netResult?.network ?? null;
+ if (network) localStorage.setItem(STORAGE_KEY, network);
setState((s) => ({
...s,
address: addrResult?.address ?? null,
- network: netResult?.network ?? null,
+ network,
isConnected: !!addrResult?.address,
isLoading: false,
error: null,
balanceError: null,
+ connectionStatus: addrResult?.address ? "connected" : "error",
}));
} catch (err) {
+ if (connectTimeoutRef.current) {
+ clearTimeout(connectTimeoutRef.current);
+ connectTimeoutRef.current = null;
+ }
setState((s) => ({
...s,
isLoading: false,
error: err instanceof Error ? err.message : "Failed to connect wallet",
+ connectionStatus: "error",
}));
}
}, []);
+ const reconnect = useCallback(async () => {
+ setState((s) => ({ ...s, error: null, connectionStatus: "connecting" }));
+ await connect();
+ }, [connect]);
+
const disconnect = useCallback(() => {
- setState((s) => ({
- ...s,
- address: null,
- network: null,
- isConnected: false,
- isLoading: false,
- error: null,
- balanceError: null,
- balances: [],
- totalUsdValue: 0,
- isBalancesLoading: false,
- lastBalanceSync: null,
- }));
+ localStorage.removeItem(STORAGE_KEY);
+ queryClient.removeQueries({ queryKey: ["balances"] });
+ setState({ ...INITIAL_STATE, connectionStatus: "idle" });
}, []);
return (
-
+
{children}
);
diff --git a/frontend/app/dashboard/analytics/loading.tsx b/frontend/app/dashboard/analytics/loading.tsx
new file mode 100644
index 000000000..afb857f68
--- /dev/null
+++ b/frontend/app/dashboard/analytics/loading.tsx
@@ -0,0 +1,14 @@
+import { ChartSkeleton, DashboardCardSkeleton } from "../../components/ui/LoadingState";
+
+export default function AnalyticsLoading() {
+ return (
+
+ );
+}
diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/dashboard/analytics/page.tsx
index 6e9179527..5bf1a65f7 100644
--- a/frontend/app/dashboard/analytics/page.tsx
+++ b/frontend/app/dashboard/analytics/page.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { MoreHorizontal, PieChart } from "lucide-react";
import PortfolioPerformanceChart from "./PortfolioPerformanceChart";
import AnalyticsComparisonGrid from "./AnalyticsComparisonGrid";
+import { ChartSkeleton, DashboardCardSkeleton } from "../../components/ui/LoadingState";
export const metadata = { title: "Analytics – Nestera" };
diff --git a/frontend/app/dashboard/governance/GovernanceClient.tsx b/frontend/app/dashboard/governance/GovernanceClient.tsx
index f1ef24a9a..1ef265ed9 100644
--- a/frontend/app/dashboard/governance/GovernanceClient.tsx
+++ b/frontend/app/dashboard/governance/GovernanceClient.tsx
@@ -6,6 +6,7 @@ import PassedProposalCard, {
type PassedProposal,
} from "@/app/components/dashboard/PassedProposalCard";
import ProposalCard from "@/app/components/dashboard/ProposalCard";
+import { Button } from "@/app/components/ui/Button";
export default function GovernanceClient() {
const [activeTab, setActiveTab] = useState("Overview");
@@ -131,19 +132,16 @@ export default function GovernanceClient() {
{tabs.map((tab) => {
const TabIcon = tab.icon;
return (
- }
onClick={() => setActiveTab(tab.label)}
- className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-semibold transition-colors ${
- activeTab === tab.label
- ? "bg-cyan-500/12 text-cyan-300"
- : "text-[#6b99a3] hover:text-white hover:bg-white/5"
- }`}
+ className={activeTab === tab.label ? "bg-cyan-500/12 text-cyan-300" : "text-[#6b99a3]"}
>
-
{tab.label}
-
+
);
})}
diff --git a/frontend/app/dashboard/notifications/page.tsx b/frontend/app/dashboard/notifications/page.tsx
index 3a16f2d3b..fe4969d7b 100644
--- a/frontend/app/dashboard/notifications/page.tsx
+++ b/frontend/app/dashboard/notifications/page.tsx
@@ -2,7 +2,7 @@
import React, { useState } from "react";
import { Bell, CheckCheck, ArrowUpRight, ShieldCheck, Target, Megaphone } from "lucide-react";
-import Button from "../../../components/ui/Button";
+import { Button } from "@/app/components/ui/Button";
type NotifType = "transaction" | "governance" | "milestone" | "announcement";
@@ -79,10 +79,11 @@ export default function NotificationsPage() {