Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions frontend/src/components/SupportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -124,6 +125,49 @@ export function SupportPanel() {
<div className="space-y-1.5">
<div className="flex justify-between items-end">
<label className="text-xs font-semibold text-white" htmlFor="amount-input">Amount</label>
<div className="text-[10px] text-slate-400" aria-live="polite" aria-atomic="true">
<AnimatePresence mode="wait" initial={false}>
{loadingBalance ? (
<motion.div
key="loading"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.18 }}
className="flex items-center gap-2"
>
<span className="sr-only">Loading balance...</span>
<Spinner size="xs" aria-hidden="true" />
</motion.div>
) : isUnfunded ? (
<motion.a
key="unfunded"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.18 }}
href="https://laboratory.stellar.org/#account-creator?network=testnet"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-yellow-400 hover:underline"
>
Unfunded (Fund on Testnet)
</motion.a>
) : (
// Keying by value animates the figure each time polling syncs a new balance.
<motion.span
key={`balance-${selectedBalance}-${assetCode}`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.18 }}
className="inline-block"
aria-label={`Available balance: ${selectedBalance} ${assetCode}`}
>
Available: {selectedBalance} {assetCode}
</motion.span>
)}
</AnimatePresence>
{/* Visual-only — announcements are handled by the live region above. */}
<div className="text-[10px] text-slate-400">
{loadingBalance ? (
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("");
// 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) {
Expand Down Expand Up @@ -91,7 +95,6 @@ export default function ThemeToggle() {
aria-describedby="theme-description"
title={getTitle()}
disabled={isLoading}
role="button"
>
<AnimatePresence mode="wait" initial={false}>
{error ? (
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
>
<svg
Expand Down Expand Up @@ -190,6 +193,7 @@ export default function ThemeToggle() {
<div id="theme-description" className="sr-only">
Use this button to cycle through light, dark, and system themes. The system theme follows your device preference.
</div>
</button>
</>
);
}
67 changes: 54 additions & 13 deletions frontend/src/hooks/useBalanceSync.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
*/
Expand All @@ -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<Balance[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [state, dispatch] = useReducer(balanceSyncReducer, initialState);
const { balances, isLoading, lastUpdated, error } = state;
const abortControllerRef = useRef<AbortController | null>(null);

const fetchBalances = useCallback(async () => {
Expand All @@ -44,7 +85,7 @@ export function useBalanceSync(
}
abortControllerRef.current = new AbortController();

setIsLoading(true);
dispatch({ type: "FETCH_START" });
try {
let newBalances: Balance[] = [];

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -121,6 +161,7 @@ export function useBalanceSync(
balances,
isLoading,
lastUpdated,
error,
refresh: fetchBalances,
applyOptimistic,
isStale: lastUpdated ? Date.now() - lastUpdated.getTime() > pollingInterval * 2 : true,
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/lib/theme-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}

Expand Down