diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5b559a32..dce5ff80 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -139,6 +139,7 @@ function App() { unreadCount={unreadCount} onMarkNotificationsRead={markAllRead} notificationsLoading={notificationsLoading} + apiReachable={healthy} />
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 8d0559f7..04e78857 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,10 +1,9 @@ -import { useState, useEffect } from 'react'; import CopyButton from './ui/copy-button'; import NotificationBell from './NotificationBell'; import { BANNER_HEIGHT_CLASS } from './OfflineBanner'; import { useTheme } from '../context/ThemeContext'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; -import { NETWORK_NAME, STACKS_API_BASE } from '../config/contracts'; +import { NETWORK_NAME } from '../config/contracts'; import { formatAddress } from '../lib/utils'; import { getMainnetAddress } from '../utils/stacks'; import { Sun, Moon } from 'lucide-react'; @@ -25,25 +24,9 @@ import { Sun, Moon } from 'lucide-react'; * @param {Function} props.onMarkNotificationsRead - Callback to mark all read. * @param {boolean} props.notificationsLoading - Whether notifications are loading. */ -export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading }) { +export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading, apiReachable = null }) { const { theme, toggleTheme } = useTheme(); const isOnline = useOnlineStatus(); - const [apiReachable, setApiReachable] = useState(null); - - useEffect(() => { - let cancelled = false; - const checkApi = async () => { - try { - const res = await fetch(`${STACKS_API_BASE}/v2/info`, { signal: AbortSignal.timeout(5000) }); - if (!cancelled) setApiReachable(res.ok); - } catch { - if (!cancelled) setApiReachable(false); - } - }; - checkApi(); - const interval = setInterval(checkApi, 30000); - return () => { cancelled = true; clearInterval(interval); }; - }, []); const networkLabel = NETWORK_NAME.charAt(0).toUpperCase() + NETWORK_NAME.slice(1); diff --git a/frontend/src/hooks/useStxPrice.js b/frontend/src/hooks/useStxPrice.js index f54b79dc..cdcf087c 100644 --- a/frontend/src/hooks/useStxPrice.js +++ b/frontend/src/hooks/useStxPrice.js @@ -2,7 +2,35 @@ import { useState, useEffect, useCallback, useRef } from "react"; const COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd"; -const REFRESH_INTERVAL = 60_000; +const REFRESH_INTERVAL = 120_000; +const RATE_LIMIT_RETRY_MS = 300_000; +const CACHE_KEY = "tipstream:stx-price"; +const CACHE_TTL_MS = 120_000; + +function readCachedPrice() { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (typeof parsed?.price !== "number" || typeof parsed?.cachedAt !== "number") { + return null; + } + return parsed; + } catch { + return null; + } +} + +function writeCachedPrice(nextPrice) { + try { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ price: nextPrice, cachedAt: Date.now() }) + ); + } catch { + // Ignore storage errors. + } +} export function useStxPrice() { const [price, setPrice] = useState(null); @@ -10,17 +38,33 @@ export function useStxPrice() { const [error, setError] = useState(null); const intervalRef = useRef(null); - const fetchPrice = useCallback(async () => { + const fetchPrice = useCallback(async (forceNetwork = false) => { try { - const res = await fetch(COINGECKO_URL); + if (!forceNetwork) { + const cached = readCachedPrice(); + if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) { + setPrice(cached.price); + setError(null); + setLoading(false); + return; + } + } + + const demoKey = import.meta.env?.VITE_COINGECKO_DEMO_API_KEY; + const headers = demoKey ? { "x-cg-demo-api-key": demoKey } : undefined; + const res = await fetch(COINGECKO_URL, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const usd = data?.stacks?.usd; if (typeof usd !== "number") throw new Error("Invalid price data"); setPrice(usd); + writeCachedPrice(usd); setError(null); } catch (err) { setError(err.message); + if (err.message === "HTTP 429") { + console.warn("CoinGecko rate limit reached. Retrying with backoff."); + } } finally { setLoading(false); } @@ -28,10 +72,20 @@ export function useStxPrice() { useEffect(() => { fetchPrice(); - intervalRef.current = setInterval(fetchPrice, REFRESH_INTERVAL); + intervalRef.current = setInterval(() => { + fetchPrice(true); + }, REFRESH_INTERVAL); return () => clearInterval(intervalRef.current); }, [fetchPrice]); + useEffect(() => { + if (error !== "HTTP 429") return; + const timeoutId = setTimeout(() => { + fetchPrice(true); + }, RATE_LIMIT_RETRY_MS); + return () => clearTimeout(timeoutId); + }, [error, fetchPrice]); + const toUsd = useCallback( (stxAmount) => { if (price === null || stxAmount === null || stxAmount === undefined) return null; @@ -40,5 +94,5 @@ export function useStxPrice() { [price] ); - return { price, loading, error, toUsd, refetch: fetchPrice }; + return { price, loading, error, toUsd, refetch: () => fetchPrice(true) }; } diff --git a/frontend/src/test/Header.test.jsx b/frontend/src/test/Header.test.jsx index 09506a10..8644c9da 100644 --- a/frontend/src/test/Header.test.jsx +++ b/frontend/src/test/Header.test.jsx @@ -19,7 +19,6 @@ vi.mock('../context/ThemeContext', () => ({ vi.mock('../config/contracts', () => ({ NETWORK_NAME: 'mainnet', - STACKS_API_BASE: 'https://api.hiro.so', })); vi.mock('../utils/stacks', () => ({ @@ -38,6 +37,7 @@ const defaultProps = { unreadCount: 0, onMarkNotificationsRead: vi.fn(), notificationsLoading: false, + apiReachable: null, }; function setOnline(value) { diff --git a/frontend/src/test/useStxPrice.test.js b/frontend/src/test/useStxPrice.test.js index 1de040f5..db962242 100644 --- a/frontend/src/test/useStxPrice.test.js +++ b/frontend/src/test/useStxPrice.test.js @@ -5,6 +5,7 @@ import { useStxPrice } from '../hooks/useStxPrice'; describe('useStxPrice', () => { beforeEach(() => { vi.useFakeTimers(); + localStorage.clear(); }); afterEach(() => { @@ -229,7 +230,7 @@ describe('useStxPrice', () => { expect(result.current.toUsd(100)).toBe('0.00'); }); - it('polls for updated price after 60 seconds', async () => { + it('polls for updated price after 120 seconds', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ stacks: { usd: 1.0 } }), @@ -249,7 +250,7 @@ describe('useStxPrice', () => { }); await act(async () => { - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(120_000); }); expect(result.current.price).toBe(1.5); @@ -294,7 +295,7 @@ describe('useStxPrice', () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); await act(async () => { - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(120_000); }); expect(result.current.price).toBe(1.25); @@ -315,6 +316,7 @@ describe('useStxPrice', () => { expect(global.fetch).toHaveBeenCalledWith( 'https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd', + expect.any(Object), ); });