From ff7c6deb86487211d39019b29f136a84ec1a7a35 Mon Sep 17 00:00:00 2001 From: queentiffany1111-cloud Date: Sat, 30 May 2026 14:20:09 +0000 Subject: [PATCH] feat(ui): add loading spinner/progress indicator for wallet connection - Add CONNECTION_STEPS enum and connectionStep state to useFreighter - runSep10 now calls onStep callback at each SEP-10 stage - connect() guards against duplicate attempts while loading - New WalletConnectionProgress component: spinner + step message + counter - NavBar connect button disabled during loading; shows progress below it - Error state shown after failed connection attempt Closes #288 --- frontend/src/components/NavBar.jsx | 42 +++++++----- .../components/WalletConnectionProgress.jsx | 66 +++++++++++++++++++ frontend/src/hooks/useFreighter.jsx | 24 ++++++- 3 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/WalletConnectionProgress.jsx diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index 5ecb1b8..462f561 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; import DarkModeToggle from './DarkModeToggle'; import Tooltip from './Tooltip'; +import WalletConnectionProgress from './WalletConnectionProgress'; import { useAuth } from '../hooks/useFreighter'; const NAV_LINKS = [ @@ -19,7 +20,7 @@ function truncate(addr) { } function WalletIndicator() { - const { publicKey, connect, disconnect } = useAuth(); + const { publicKey, connect, disconnect, loading, connectionStep, error } = useAuth(); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const ref = useRef(null); @@ -34,22 +35,29 @@ function WalletIndicator() { if (!publicKey) { return ( - +
+ + {loading && } + {!loading && error && } +
); } diff --git a/frontend/src/components/WalletConnectionProgress.jsx b/frontend/src/components/WalletConnectionProgress.jsx new file mode 100644 index 0000000..136bcaa --- /dev/null +++ b/frontend/src/components/WalletConnectionProgress.jsx @@ -0,0 +1,66 @@ +import { CONNECTION_STEPS } from '../hooks/useFreighter'; + +const STEP_ORDER = [ + CONNECTION_STEPS.CHECKING, + CONNECTION_STEPS.REQUESTING_KEY, + CONNECTION_STEPS.REQUESTING_CHALLENGE, + CONNECTION_STEPS.SIGNING, + CONNECTION_STEPS.VERIFYING, +]; + +const spinnerStyle = { + width: 16, + height: 16, + border: '2px solid #334155', + borderTopColor: '#38bdf8', + borderRadius: '50%', + display: 'inline-block', + animation: 'spin 0.7s linear infinite', + flexShrink: 0, +}; + +/** + * Shown while wallet connection is in progress. + * Renders a spinner + current step message. + */ +export default function WalletConnectionProgress({ step, error }) { + if (!step && !error) return null; + + return ( +
+ {error ? ( + + ) : ( + <> +
+ ); +} diff --git a/frontend/src/hooks/useFreighter.jsx b/frontend/src/hooks/useFreighter.jsx index dc65857..19bf8b9 100644 --- a/frontend/src/hooks/useFreighter.jsx +++ b/frontend/src/hooks/useFreighter.jsx @@ -10,24 +10,37 @@ const AuthContext = createContext(null); // Only persist publicKey and role — token is kept in memory only const STORAGE_KEY = 'vaccichain_wallet'; +export const CONNECTION_STEPS = { + IDLE: null, + CHECKING: 'Checking Freighter wallet…', + REQUESTING_KEY: 'Requesting public key…', + REQUESTING_CHALLENGE: 'Requesting SEP-10 challenge…', + SIGNING: 'Waiting for wallet signature…', + VERIFYING: 'Verifying signature…', +}; + export function AuthProvider({ children }) { const toast = useToast(); const [publicKey, setPublicKey] = useState(null); const [role, setRole] = useState(null); const [loading, setLoading] = useState(false); + const [connectionStep, setConnectionStep] = useState(CONNECTION_STEPS.IDLE); const [error, setError] = useState(null); const [freighterInstalled, setFreighterInstalled] = useState(() => typeof window !== 'undefined' && !!window.freighter); // Token lives only in memory — never written to localStorage const tokenRef = useRef(null); - const runSep10 = useCallback(async (pk) => { + const runSep10 = useCallback(async (pk, onStep) => { + onStep?.(CONNECTION_STEPS.REQUESTING_CHALLENGE); const challengeRes = await fetch('/v1/auth/sep10', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ public_key: pk }), }); const { transaction, nonce } = await challengeRes.json(); + onStep?.(CONNECTION_STEPS.SIGNING); const signedXDR = await signTransaction(transaction, { network: 'TESTNET' }); + onStep?.(CONNECTION_STEPS.VERIFYING); const verifyRes = await fetch('/v1/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -39,16 +52,19 @@ export function AuthProvider({ children }) { }, []); const connect = useCallback(async () => { + if (loading) return; // prevent duplicate attempts setLoading(true); setError(null); + setConnectionStep(CONNECTION_STEPS.CHECKING); try { const connected = await isConnected(); if (!connected) { setFreighterInstalled(false); throw new Error('Freighter wallet not found. Please install it.'); } + setConnectionStep(CONNECTION_STEPS.REQUESTING_KEY); const pk = await getPublicKey(); - const data = await runSep10(pk); + const data = await runSep10(pk, setConnectionStep); setPublicKey(pk); tokenRef.current = data.token; setRole(data.role); @@ -63,8 +79,9 @@ export function AuthProvider({ children }) { throw e; } finally { setLoading(false); + setConnectionStep(CONNECTION_STEPS.IDLE); } - }, [runSep10, toast]); + }, [loading, runSep10, toast]); const disconnect = useCallback(() => { setPublicKey(null); @@ -130,6 +147,7 @@ export function AuthProvider({ children }) { apiFetch, isConnected: isConnectedState, loading, + connectionStep, error, }}> {children}