+
+ {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");
+ }
}
}