From 5b559e178a7f2a5243dfc919786a0429af84b37e Mon Sep 17 00:00:00 2001
From: king-aj-the-first
Date: Sat, 30 May 2026 16:26:52 +0000
Subject: [PATCH] feat(frontend): reusable Button, wallet persistence, loading
states, query caching
---
frontend/app/components/FAQ.tsx | 11 +-
frontend/app/components/Navbar.tsx | 24 +-
frontend/app/components/Newsletter.tsx | 15 +-
frontend/app/components/ThemeToggle.tsx | 18 +-
.../app/components/WalletReconnectBanner.tsx | 40 +++
.../dashboard/ContractDetailsCard.tsx | 8 +-
.../components/dashboard/FeaturedGoalCard.tsx | 9 +-
.../app/components/dashboard/GoalCard.tsx | 5 +-
.../components/dashboard/GoalOverviewCard.tsx | 23 +-
.../dashboard/NetworkSwitchModal.tsx | 38 ++-
.../dashboard/PassedProposalCard.tsx | 5 +-
.../app/components/dashboard/ProposalCard.tsx | 14 +-
.../components/dashboard/QuickActionsGrid.tsx | 19 +-
.../components/dashboard/SavingsPoolCard.tsx | 7 +-
frontend/app/components/dashboard/Sidebar.tsx | 21 +-
.../dashboard/WalletBalanceCard.tsx | 44 +--
frontend/app/components/ui/Button.tsx | 92 ++++++
frontend/app/components/ui/LoadingState.tsx | 104 ++++++-
frontend/app/context/QueryProvider.tsx | 55 ++++
frontend/app/context/WalletContext.tsx | 261 +++++++++++-------
frontend/app/dashboard/analytics/loading.tsx | 14 +
frontend/app/dashboard/analytics/page.tsx | 1 +
.../dashboard/governance/GovernanceClient.tsx | 16 +-
frontend/app/dashboard/notifications/page.tsx | 24 +-
frontend/app/dashboard/page.tsx | 36 ++-
frontend/app/dashboard/portfolio/page.tsx | 19 +-
frontend/app/dashboard/profile/page.tsx | 25 +-
frontend/app/dashboard/referrals/page.tsx | 11 +-
.../app/dashboard/savings-pools/loading.tsx | 13 +
frontend/app/dashboard/savings-pools/page.tsx | 37 +--
.../app/dashboard/settings/SettingsClient.tsx | 10 +-
.../app/dashboard/transactions/loading.tsx | 13 +
frontend/app/dashboard/transactions/page.tsx | 34 ++-
frontend/app/docs/components/DocsSections.tsx | 5 +-
frontend/app/docs/components/DocsSidebar.tsx | 9 +-
frontend/app/hooks/useWalletCache.ts | 94 +++++++
frontend/app/layout.tsx | 15 +-
.../create-goal/components/CreateGoalForm.tsx | 23 +-
frontend/app/savings/loading.tsx | 16 ++
frontend/app/savings/page.tsx | 40 ++-
frontend/package.json | 3 +
frontend/pnpm-lock.yaml | 51 ++++
42 files changed, 964 insertions(+), 358 deletions(-)
create mode 100644 frontend/app/components/WalletReconnectBanner.tsx
create mode 100644 frontend/app/components/ui/Button.tsx
create mode 100644 frontend/app/context/QueryProvider.tsx
create mode 100644 frontend/app/dashboard/analytics/loading.tsx
create mode 100644 frontend/app/dashboard/savings-pools/loading.tsx
create mode 100644 frontend/app/dashboard/transactions/loading.tsx
create mode 100644 frontend/app/hooks/useWalletCache.ts
create mode 100644 frontend/app/savings/loading.tsx
diff --git a/frontend/app/components/FAQ.tsx b/frontend/app/components/FAQ.tsx
index 389077062..4ee70764d 100644
--- a/frontend/app/components/FAQ.tsx
+++ b/frontend/app/components/FAQ.tsx
@@ -1,7 +1,8 @@
-'use client';
+"use client";
import React, { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
+import { Button } from '@/app/components/ui/Button';
interface FAQItem {
question: string;
@@ -65,8 +66,10 @@ const FAQ: React.FC = () => {
: "bg-white/[0.03] border-white/[0.08] hover:border-[rgba(0,212,192,0.3)]"
)}
>
-
+
{
}
return (
-
+
);
};
@@ -123,10 +124,11 @@ const Navbar: React.FC = () => {
-
+
diff --git a/frontend/app/components/Newsletter.tsx b/frontend/app/components/Newsletter.tsx
index 051efc97c..cd1d0d920 100644
--- a/frontend/app/components/Newsletter.tsx
+++ b/frontend/app/components/Newsletter.tsx
@@ -1,6 +1,7 @@
"use client";
import React, { useState } from "react";
+import { Button } from "./ui/Button";
const Newsletter: React.FC = () => {
const [email, setEmail] = useState("");
@@ -8,8 +9,6 @@ const Newsletter: React.FC = () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (email) {
- // TODO: Implement newsletter subscription
- // Send the email to your backend API
setEmail("");
}
};
@@ -29,9 +28,7 @@ const Newsletter: React.FC = () => {
onSubmit={handleSubmit}
>
-
+
{
aria-describedby="newsletter-help"
/>
-
diff --git a/frontend/app/components/ThemeToggle.tsx b/frontend/app/components/ThemeToggle.tsx
index 67691329b..bd56d4f1b 100644
--- a/frontend/app/components/ThemeToggle.tsx
+++ b/frontend/app/components/ThemeToggle.tsx
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { Check, ChevronDown, Monitor, Moon, Sun } from "lucide-react";
+import { Button } from '@/app/components/ui/Button';
import { type Theme, useTheme } from "../context/ThemeContext";
const themeOptions: Array<{
@@ -82,14 +83,17 @@ export default function ThemeToggle({
return (
-
setIsOpen((current) => !current)}
aria-label={`Theme: ${activeOption.label}`}
aria-haspopup="menu"
aria-expanded={isOpen}
+ variant="ghost"
+ size={compact ? "sm" : "md"}
className={clsx(
- "inline-flex items-center rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-strong)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
+ "inline-flex items-center",
+ "shadow-sm hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-strong)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
compact
? "h-10 w-10 justify-center"
: "gap-2 px-3.5 py-2.5 text-sm font-medium",
@@ -125,7 +129,7 @@ export default function ThemeToggle({
const selected = theme === value;
return (
-
diff --git a/frontend/app/components/WalletReconnectBanner.tsx b/frontend/app/components/WalletReconnectBanner.tsx
new file mode 100644
index 000000000..65e5a00ae
--- /dev/null
+++ b/frontend/app/components/WalletReconnectBanner.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import React from "react";
+import { WifiOff, RefreshCw } from "lucide-react";
+import { useWallet } from "../context/WalletContext";
+import { Button } from "./ui/Button";
+
+export function WalletReconnectBanner() {
+ const { connectionStatus, reconnect, isLoading } = useWallet();
+
+ if (connectionStatus !== "locked" && connectionStatus !== "disconnected") return null;
+
+ const isLocked = connectionStatus === "locked";
+
+ return (
+
+
+
+
+ {isLocked
+ ? "Your wallet was locked. Reconnect to continue."
+ : "Wallet disconnected. Reconnect to restore your session."}
+
+
+
}
+ loading={isLoading}
+ onClick={reconnect}
+ className="border-amber-500/40 text-amber-200 hover:bg-amber-500/10 shrink-0"
+ >
+ Reconnect
+
+
+ );
+}
diff --git a/frontend/app/components/dashboard/ContractDetailsCard.tsx b/frontend/app/components/dashboard/ContractDetailsCard.tsx
index 896713d4a..453060e96 100644
--- a/frontend/app/components/dashboard/ContractDetailsCard.tsx
+++ b/frontend/app/components/dashboard/ContractDetailsCard.tsx
@@ -2,6 +2,7 @@
import React from "react";
import { Copy, FileCode, Calendar } from "lucide-react";
+import { Button } from '@/app/components/ui/Button';
export type RiskLevel = "Low Risk" | "Medium Risk" | "High Risk";
@@ -108,17 +109,20 @@ const ContractDetailsCard: React.FC = ({
{truncateAddress(contract.contractId, 8)}
-
-
+
{copied && (
Copied to clipboard
diff --git a/frontend/app/components/dashboard/FeaturedGoalCard.tsx b/frontend/app/components/dashboard/FeaturedGoalCard.tsx
index c97d03a82..5b5447e91 100644
--- a/frontend/app/components/dashboard/FeaturedGoalCard.tsx
+++ b/frontend/app/components/dashboard/FeaturedGoalCard.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Plane, Calendar, ChevronRight } from 'lucide-react';
+import { Button } from "../ui/Button";
import CircularProgress from './CircularProgress';
interface FeaturedGoalCardProps {
@@ -82,13 +83,13 @@ const FeaturedGoalCard: React.FC = ({
-
+
Contribute Now
-
-
+
+
View Details
-
+
diff --git a/frontend/app/components/dashboard/GoalCard.tsx b/frontend/app/components/dashboard/GoalCard.tsx
index 77d4948c0..d0e586313 100644
--- a/frontend/app/components/dashboard/GoalCard.tsx
+++ b/frontend/app/components/dashboard/GoalCard.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { Button } from '@/app/components/ui/Button';
import Link from "next/link";
import { Calendar, ChevronRight } from "lucide-react";
@@ -111,9 +112,9 @@ export default function GoalCard({
-
+
Contribute
-
+
= ({
{/* ── Action buttons ── */}
-
-
- Edit Goal
-
-
-
- Add Funds
-
+
+
+ Edit Goal
+
+
+
+ Add Funds
+
);
diff --git a/frontend/app/components/dashboard/NetworkSwitchModal.tsx b/frontend/app/components/dashboard/NetworkSwitchModal.tsx
index 35be94929..a14df84d5 100644
--- a/frontend/app/components/dashboard/NetworkSwitchModal.tsx
+++ b/frontend/app/components/dashboard/NetworkSwitchModal.tsx
@@ -2,6 +2,7 @@
import React, { useRef } from "react";
import { X, ExternalLink, AlertTriangle, Shield } from "lucide-react";
+import { Button } from '@/app/components/ui/Button';
import { getNetworkConfig } from "../../constants/networks";
import { useFocusTrap } from "../../hooks/useFocusTrap";
import { useToast } from "../../context/ToastContext";
@@ -121,15 +122,17 @@ const NetworkSwitchModal: React.FC = ({
-
-
+
{/* Modal Content - Scrollable */}
@@ -252,29 +255,24 @@ const NetworkSwitchModal: React.FC = ({
{/* Modal Footer - Action Buttons */}
-
Close
-
-
+ {
- e.currentTarget.style.backgroundColor = "#0fa3a3";
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.backgroundColor = "#08c1c1";
- }}
+ leftIcon={}
>
Open Freighter
-
-
+
diff --git a/frontend/app/components/dashboard/PassedProposalCard.tsx b/frontend/app/components/dashboard/PassedProposalCard.tsx
index 46ac1a740..06bfff4b9 100644
--- a/frontend/app/components/dashboard/PassedProposalCard.tsx
+++ b/frontend/app/components/dashboard/PassedProposalCard.tsx
@@ -1,6 +1,7 @@
"use client";
import React from "react";
+import { Button } from "../ui/Button";
import { CheckCircle2, ChevronRight } from "lucide-react";
export interface PassedProposal {
@@ -74,9 +75,9 @@ export default function PassedProposalCard({ proposal }: { proposal: PassedPropo
{/* Mobile-only full-width Vote button placed as the last row */}
-
{ctaLabel}
-
+
);
diff --git a/frontend/app/components/dashboard/QuickActionsGrid.tsx b/frontend/app/components/dashboard/QuickActionsGrid.tsx
index 410e94efd..6abee7935 100644
--- a/frontend/app/components/dashboard/QuickActionsGrid.tsx
+++ b/frontend/app/components/dashboard/QuickActionsGrid.tsx
@@ -2,6 +2,7 @@
import React from "react";
import { ArrowDownCircle, ArrowUpCircle, Repeat, Link } from "lucide-react";
+import { Button } from "../ui/Button";
const actions = [
{ label: "Deposit", icon: ArrowDownCircle },
@@ -16,15 +17,17 @@ const QuickActionsGrid: React.FC = () => {
{actions.map((a) => {
const Icon = a.icon as React.ElementType;
return (
-
-
-
-
- {a.label}
-
+
+
+
+ {a.label}
+
);
})}
diff --git a/frontend/app/components/dashboard/SavingsPoolCard.tsx b/frontend/app/components/dashboard/SavingsPoolCard.tsx
index 6334e214e..1a05749e0 100644
--- a/frontend/app/components/dashboard/SavingsPoolCard.tsx
+++ b/frontend/app/components/dashboard/SavingsPoolCard.tsx
@@ -1,6 +1,7 @@
"use client";
import React from "react";
+import { Button } from "../ui/Button";
export type RiskLevel = "Low Risk" | "Medium Risk" | "High Risk";
@@ -106,12 +107,14 @@ const SavingsPoolCard: React.FC = ({
{/* Deposit Button */}
- onDeposit?.(pool.id)}
+ variant="outline"
+ size="md"
className="w-full py-3 bg-transparent border border-cyan-500/30 text-cyan-400 rounded-xl font-semibold hover:bg-cyan-500/10 hover:border-cyan-500/50 transition-all duration-200 active:scale-[0.98] group-hover:border-cyan-500/50"
>
Deposit
-
+
);
};
diff --git a/frontend/app/components/dashboard/Sidebar.tsx b/frontend/app/components/dashboard/Sidebar.tsx
index 0a449f855..400e1f49b 100644
--- a/frontend/app/components/dashboard/Sidebar.tsx
+++ b/frontend/app/components/dashboard/Sidebar.tsx
@@ -21,6 +21,7 @@ import {
Users,
X,
} from "lucide-react";
+import { Button } from '@/app/components/ui/Button';
const navLinks = [
{ label: "Dashboard", href: "/dashboard", icon: Home },
@@ -74,13 +75,15 @@ const Sidebar: React.FC = () => {
Nestera
- setOpen((current) => !current)}
aria-label="Toggle menu"
>
{open ? : }
-
+
diff --git a/frontend/app/hooks/useWalletCache.ts b/frontend/app/hooks/useWalletCache.ts
new file mode 100644
index 000000000..518c8d566
--- /dev/null
+++ b/frontend/app/hooks/useWalletCache.ts
@@ -0,0 +1,94 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Horizon } from "@stellar/stellar-sdk";
+import { env } from "../lib/env";
+
+interface Balance {
+ asset_code: string;
+ balance: string;
+ asset_type: string;
+ asset_issuer?: string;
+ usd_value: number;
+}
+
+const COINGECKO_IDS: Record = {
+ XLM: "stellar",
+ USDC: "usd-coin",
+ AQUA: "aqua",
+};
+
+async function fetchPrices(): Promise> {
+ const ids = Object.values(COINGECKO_IDS).join(",");
+ const res = await fetch(`${env.coingeckoApi}/simple/price?ids=${ids}&vs_currencies=usd`);
+ if (!res.ok) throw new Error("Failed to fetch prices");
+ const data = await res.json();
+ const prices: Record = {};
+ for (const [code, id] of Object.entries(COINGECKO_IDS)) {
+ prices[code] = data[id]?.usd ?? (code === "USDC" ? 1 : 0);
+ }
+ return prices;
+}
+
+async function fetchBalances(address: string, horizonUrl: string): Promise {
+ const server = new Horizon.Server(horizonUrl);
+ const account = await server.loadAccount(address);
+ return account.balances.map((b: any) => ({
+ asset_code: b.asset_type === "native" ? "XLM" : b.asset_code,
+ balance: b.balance,
+ asset_type: b.asset_type,
+ asset_issuer: b.asset_issuer,
+ usd_value: 0, // enriched below
+ }));
+}
+
+/** Cached price data — refreshes every 5 minutes */
+export function usePrices() {
+ return useQuery({
+ queryKey: ["prices"],
+ queryFn: fetchPrices,
+ staleTime: 5 * 60_000, // 5 minutes
+ gcTime: 10 * 60_000,
+ refetchInterval: 5 * 60_000,
+ });
+}
+
+/** Cached wallet balances — refreshes every 60 seconds, invalidated on disconnect */
+export function useWalletBalances(
+ address: string | null,
+ network: string | null,
+ horizonUrl: string,
+) {
+ const { data: prices } = usePrices();
+
+ return useQuery({
+ queryKey: ["balances", address],
+ queryFn: async () => {
+ if (!address) return [];
+ const rawBalances = await fetchBalances(address, horizonUrl);
+ let total = 0;
+ const enriched = rawBalances.map((b) => {
+ const price = prices?.[b.asset_code] ?? (b.asset_code === "USDC" ? 1 : 0);
+ const usdValue = parseFloat(b.balance) * price;
+ total += usdValue;
+ return { ...b, usd_value: usdValue };
+ });
+ return enriched;
+ },
+ enabled: !!address,
+ staleTime: 30_000, // 30 seconds
+ gcTime: 5 * 60_000,
+ refetchOnWindowFocus: true,
+ refetchInterval: 60_000, // 1 minute
+ });
+}
+
+/** Call this to invalidate balance cache (e.g. after a transaction or on disconnect) */
+export function useInvalidateBalances() {
+ const queryClient = useQueryClient();
+ return (address?: string | null) => {
+ if (address) {
+ queryClient.invalidateQueries({ queryKey: ["balances", address] });
+ } else {
+ queryClient.invalidateQueries({ queryKey: ["balances"] });
+ }
+ };
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 585c08cdb..db6042d75 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -4,6 +4,8 @@ import type { Metadata } from "next";
import { ThemeProvider } from "./context/ThemeContext";
import { WalletProvider } from "./context/WalletContext";
import { ToastProvider } from "./context/ToastContext";
+import { WalletReconnectBanner } from "./components/WalletReconnectBanner";
+import { QueryProvider } from "./context/QueryProvider";
const BASE_URL = "https://nestera.app";
@@ -54,11 +56,14 @@ export default function RootLayout({
Skip to content
-
-
- {children}
-
-
+
+
+
+
+ {children}
+
+
+