diff --git a/frontend/src/components/SupportPanel.tsx b/frontend/src/components/SupportPanel.tsx index 6524053..7f0caa9 100644 --- a/frontend/src/components/SupportPanel.tsx +++ b/frontend/src/components/SupportPanel.tsx @@ -6,6 +6,7 @@ import { getAccountBalances, type AssetBalance } from "@/lib/stellar"; import { Spinner } from "./ui/Spinner"; import { TransactionResultModal } from "./TransactionResultModal"; import { toast } from "sonner"; +import { motion, AnimatePresence } from "framer-motion"; import { useBalanceSync } from "@/hooks/useBalanceSync"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; @@ -124,6 +125,49 @@ export function SupportPanel() {
+
+ + {loadingBalance ? ( + + Loading balance... + + ) : isUnfunded ? ( + + Unfunded (Fund on Testnet) + + ) : ( + // Keying by value animates the figure each time polling syncs a new balance. + + Available: {selectedBalance} {assetCode} + + )} + {/* Visual-only — announcements are handled by the live region above. */}
{loadingBalance ? ( diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index d8f1d64..8cdb97e 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -2,12 +2,16 @@ import { useThemeState, useThemeActions } from "@/lib/theme-context"; import { useCallback, useEffect, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; export default function ThemeToggle() { const { theme, resolvedTheme, isMounted, isLoading, error } = useThemeState(); const { toggleTheme, clearError } = useThemeActions(); const [announcement, setAnnouncement] = useState(""); + // Honour the user's reduced-motion preference so the icon swap doesn't + // animate for people who find motion distracting (a standard a11y audit item). + const shouldReduceMotion = useReducedMotion(); + const iconTransition = { duration: shouldReduceMotion ? 0 : 0.2 }; const handleThemeToggle = useCallback(() => { if (error) { @@ -91,7 +95,6 @@ export default function ThemeToggle() { aria-describedby="theme-description" title={getTitle()} disabled={isLoading} - role="button" > {error ? ( @@ -100,7 +103,7 @@ export default function ThemeToggle() { initial={{ scale: 0.5, opacity: 0, rotate: -45 }} animate={{ scale: 1, opacity: 1, rotate: 0 }} exit={{ scale: 0.5, opacity: 0, rotate: 45 }} - transition={{ duration: 0.2 }} + transition={iconTransition} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" @@ -120,7 +123,7 @@ export default function ThemeToggle() { initial={{ scale: 0.5, opacity: 0, rotate: -45 }} animate={{ scale: 1, opacity: 1, rotate: 0 }} exit={{ scale: 0.5, opacity: 0, rotate: 45 }} - transition={{ duration: 0.2 }} + transition={iconTransition} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" @@ -140,7 +143,7 @@ export default function ThemeToggle() { initial={{ scale: 0.5, opacity: 0, rotate: -45 }} animate={{ scale: 1, opacity: 1, rotate: 0 }} exit={{ scale: 0.5, opacity: 0, rotate: 45 }} - transition={{ duration: 0.2 }} + transition={iconTransition} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" @@ -160,7 +163,7 @@ export default function ThemeToggle() { initial={{ scale: 0.5, opacity: 0, rotate: -45 }} animate={{ scale: 1, opacity: 1, rotate: 0 }} exit={{ scale: 0.5, opacity: 0, rotate: 45 }} - transition={{ duration: 0.2 }} + transition={iconTransition} className="relative flex items-center justify-center" > Use this button to cycle through light, dark, and system themes. The system theme follows your device preference.
+ ); } diff --git a/frontend/src/hooks/useBalanceSync.ts b/frontend/src/hooks/useBalanceSync.ts index 3791308..2155550 100644 --- a/frontend/src/hooks/useBalanceSync.ts +++ b/frontend/src/hooks/useBalanceSync.ts @@ -1,5 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { toast } from "sonner"; +import { useEffect, useReducer, useCallback, useRef } from "react"; interface Balance { code: string; @@ -14,6 +13,49 @@ interface UseBalanceSyncOptions { horizonUrl?: string; } +interface BalanceSyncState { + balances: Balance[]; + isLoading: boolean; + lastUpdated: Date | null; + error: string | null; +} + +type BalanceSyncAction = + | { type: "FETCH_START" } + | { type: "FETCH_SUCCESS"; balances: Balance[]; at: Date } + | { type: "FETCH_ERROR"; error: string }; + +const initialState: BalanceSyncState = { + balances: [], + isLoading: false, + lastUpdated: null, + error: null, +}; + +// Consolidating the related pieces of state into a reducer keeps the +// fetch lifecycle (start → success → error) explicit and prevents the +// inconsistent intermediate renders that separate setState calls can cause. +function balanceSyncReducer( + state: BalanceSyncState, + action: BalanceSyncAction, +): BalanceSyncState { + switch (action.type) { + case "FETCH_START": + return { ...state, isLoading: true, error: null }; + case "FETCH_SUCCESS": + return { + balances: action.balances, + isLoading: false, + lastUpdated: action.at, + error: null, + }; + case "FETCH_ERROR": + return { ...state, isLoading: false, error: action.error }; + default: + return state; + } +} + /** * Hook for real-time balance synchronization with polling and race condition prevention. */ @@ -22,16 +64,15 @@ export function useBalanceSync( apiKey: string | null | undefined, options: UseBalanceSyncOptions = {} ) { - const { - pollingInterval = 30000, - onUpdate, + const { + pollingInterval = 30000, + onUpdate, enabled = true, address = null, horizonUrl = "https://horizon-testnet.stellar.org" } = options; - const [balances, setBalances] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [lastUpdated, setLastUpdated] = useState(null); + const [state, dispatch] = useReducer(balanceSyncReducer, initialState); + const { balances, isLoading, lastUpdated, error } = state; const abortControllerRef = useRef(null); const fetchBalances = useCallback(async () => { @@ -44,7 +85,7 @@ export function useBalanceSync( } abortControllerRef.current = new AbortController(); - setIsLoading(true); + dispatch({ type: "FETCH_START" }); try { let newBalances: Balance[] = []; @@ -78,14 +119,13 @@ export function useBalanceSync( })); } - setBalances(newBalances); - setLastUpdated(new Date()); + dispatch({ type: "FETCH_SUCCESS", balances: newBalances, at: new Date() }); onUpdate?.(newBalances); } catch (error) { if (error instanceof Error && error.name === "AbortError") return; + const message = error instanceof Error ? error.message : "Balance sync failed"; console.error("Balance sync error:", error); - } finally { - setIsLoading(false); + dispatch({ type: "FETCH_ERROR", error: message }); } }, [merchantId, apiKey, enabled, onUpdate, address, horizonUrl]); @@ -121,6 +161,7 @@ export function useBalanceSync( balances, isLoading, lastUpdated, + error, refresh: fetchBalances, applyOptimistic, isStale: lastUpdated ? Date.now() - lastUpdated.getTime() > pollingInterval * 2 : true, diff --git a/frontend/src/lib/theme-context.tsx b/frontend/src/lib/theme-context.tsx index ab8b95f..76fb89d 100644 --- a/frontend/src/lib/theme-context.tsx +++ b/frontend/src/lib/theme-context.tsx @@ -71,13 +71,20 @@ export function ThemeProvider({ setError(null); } catch (err) { - // Revert optimistic update + // Revert optimistic update — restore the previous theme, the document + // class AND the meta theme-color so no part of the optimistic change + // lingers after a failed persist. setThemeState(previousTheme !== undefined ? previousTheme : defaultTheme); if (previousResolved) { setResolvedTheme(previousResolved); if (typeof globalThis !== "undefined" && globalThis.window) { globalThis.document.documentElement.classList.remove("light", "dark"); globalThis.document.documentElement.classList.add(previousResolved); + + const metaThemeColor = globalThis.document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + metaThemeColor.setAttribute("content", previousResolved === "dark" ? "#0A0A0A" : "#FFFFFF"); + } } }