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 (
- +
+ ); +} 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 = ({
- - + +
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({
- + = ({ {/* ── Action buttons ── */}
- - + +
); 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 */}
- - +
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 */}
- +
); 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}
+ ); })} 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 */} - + ); }; 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 - + 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}
+
+
+
diff --git a/frontend/app/savings/create-goal/components/CreateGoalForm.tsx b/frontend/app/savings/create-goal/components/CreateGoalForm.tsx index 4bfb978bd..b6ab6e2e9 100644 --- a/frontend/app/savings/create-goal/components/CreateGoalForm.tsx +++ b/frontend/app/savings/create-goal/components/CreateGoalForm.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { ChevronDown, X } from "lucide-react"; +import { Button } from "../../../components/ui/Button"; export default function CreateGoalForm() { const [formData, setFormData] = useState({ @@ -42,13 +43,9 @@ export default function CreateGoalForm() {

Create New Goal

- +
{/* Goal Name */} @@ -232,18 +229,12 @@ export default function CreateGoalForm() { {/* Footer Actions */}
- - + +
diff --git a/frontend/app/savings/loading.tsx b/frontend/app/savings/loading.tsx new file mode 100644 index 000000000..c86488cbd --- /dev/null +++ b/frontend/app/savings/loading.tsx @@ -0,0 +1,16 @@ +import { DashboardCardSkeleton, PoolCardSkeleton } from "../components/ui/LoadingState"; + +export default function GoalsLoading() { + return ( +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/app/savings/page.tsx b/frontend/app/savings/page.tsx index a9072381d..9121a9a85 100644 --- a/frontend/app/savings/page.tsx +++ b/frontend/app/savings/page.tsx @@ -17,6 +17,7 @@ import { ShoppingBag, } from "lucide-react"; import GoalCard, { GoalStatus } from "./components/GoalCard"; +import { Button } from "../components/ui/Button"; // export const metadata = { title: "Goal-Based Savings - Nestera" }; @@ -120,9 +121,7 @@ export default function GoalBasedSavingsPage() {

- + Behind Schedule - +
- - +
diff --git a/frontend/package.json b/frontend/package.json index 4689eac3a..a88f76e78 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,9 @@ "dependencies": { "@stellar/freighter-api": "^3.1.0", "@stellar/stellar-sdk": "^15.0.1", + "@tanstack/query-sync-storage-persister": "5.80.2", + "@tanstack/react-query": "5.80.2", + "@tanstack/react-query-persist-client": "5.80.2", "clsx": "^2.1.1", "lucide-react": "^0.575.0", "next": "^16.2.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fd39b646d..2a7f34aa7 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@stellar/stellar-sdk': specifier: ^15.0.1 version: 15.1.0 + '@tanstack/query-sync-storage-persister': + specifier: 5.80.2 + version: 5.80.2 + '@tanstack/react-query': + specifier: 5.80.2 + version: 5.80.2(react@19.2.3) + '@tanstack/react-query-persist-client': + specifier: 5.80.2 + version: 5.80.2(@tanstack/react-query@5.80.2(react@19.2.3))(react@19.2.3) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -593,6 +602,26 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@tanstack/query-core@5.80.2': + resolution: {integrity: sha512-g2Es97uwFk7omkWiH9JmtLWSA8lTUFVseIyzqbjqJEEx7qN+Hg6jbBdDvelqtakamppaJtGORQ64hEJ5S6ojSg==} + + '@tanstack/query-persist-client-core@5.80.2': + resolution: {integrity: sha512-FAmLNf6PcKebtQ70wyESJoAAUdXBE1JUVdA56sVBhDKI2GIxemcD5jwgEaqFICBOVUltFYusU7FYkL1iePg8+Q==} + + '@tanstack/query-sync-storage-persister@5.80.2': + resolution: {integrity: sha512-+gsuRkVn8tpotXgnBteXQc/gM6pMsWAYuRW3PFnUoidV9wPAVG09SumW9DNGW8DBL537zwmGGbSsXckzanJndw==} + + '@tanstack/react-query-persist-client@5.80.2': + resolution: {integrity: sha512-PQGFGnVSfL4tdgi2Bb8y/Oacc1dEGQag/39tRcQTBICI8PVMIKPOv6KY1Ti1tJkl4lDl64gSV1ttelgOrk29MA==} + peerDependencies: + '@tanstack/react-query': ^5.80.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.80.2': + resolution: {integrity: sha512-LfA0SVheJBOqC8RfJw/JbOW3yh2zuONQeWU5Prjm7yjUGUONeOedky1Bj39Cfj8MRdXrZV+DxNT7/DN/M907lQ==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -2699,6 +2728,28 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.0 + '@tanstack/query-core@5.80.2': {} + + '@tanstack/query-persist-client-core@5.80.2': + dependencies: + '@tanstack/query-core': 5.80.2 + + '@tanstack/query-sync-storage-persister@5.80.2': + dependencies: + '@tanstack/query-core': 5.80.2 + '@tanstack/query-persist-client-core': 5.80.2 + + '@tanstack/react-query-persist-client@5.80.2(@tanstack/react-query@5.80.2(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-persist-client-core': 5.80.2 + '@tanstack/react-query': 5.80.2(react@19.2.3) + react: 19.2.3 + + '@tanstack/react-query@5.80.2(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.80.2 + react: 19.2.3 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1