diff --git a/src/app/components/PriceFeedCard.tsx b/src/app/components/PriceFeedCard.tsx index 1072648..6e7b484 100644 --- a/src/app/components/PriceFeedCard.tsx +++ b/src/app/components/PriceFeedCard.tsx @@ -10,12 +10,12 @@ import { Shimmer } from "@/components/skeletons/Shimmer"; // ─── Types ──────────────────────────────────────────────────────────────────── interface PriceFeedData { - price: number; // current NGN/XLM price - change_24h: number; // 24-hour percentage change (positive = up, negative = down) - high_24h: number; // 24h high - low_24h: number; // 24h low - volume_24h: number; // 24h volume in XLM - last_updated: string; // ISO timestamp + price: number; // current NGN/XLM price + change_24h: number; // 24-hour percentage change (positive = up, negative = down) + high_24h: number; // 24h high + low_24h: number; // 24h low + volume_24h: number; // 24h volume in XLM + last_updated: string; // ISO timestamp } interface PriceFeedCardProps { @@ -24,9 +24,9 @@ interface PriceFeedCardProps { /** Asset ID for WebSocket delta updates. Defaults to 'NGN-XLM'. */ assetId?: string; /** Enable WebSocket delta updates. Defaults to true. */ - enableWebSocket?: boolean; + isVisible?: boolean; enableWebSocket?: boolean; + } - // ─── Helpers ────────────────────────────────────────────────────────────────── /** @@ -39,7 +39,9 @@ async function fetchNgnXlmFeed(): Promise { }); if (!res.ok) { - throw new Error(`Price feed request failed: ${res.status} ${res.statusText}`); + throw new Error( + `Price feed request failed: ${res.status} ${res.statusText}`, + ); } const json = await res.json(); @@ -48,7 +50,12 @@ async function fetchNgnXlmFeed(): Promise { // The guardrail requires the Up/Down arrow to be driven by `24h_change`. return { price: Number(json.price ?? json.current_price ?? 0), - change_24h: Number(json["24h_change"] ?? json.change_24h ?? json.price_change_percentage_24h ?? 0), + change_24h: Number( + json["24h_change"] ?? + json.change_24h ?? + json.price_change_percentage_24h ?? + 0, + ), high_24h: Number(json["24h_high"] ?? json.high_24h ?? 0), low_24h: Number(json["24h_low"] ?? json.low_24h ?? 0), volume_24h: Number(json["24h_volume"] ?? json.volume_24h ?? 0), @@ -90,6 +97,7 @@ const PriceFeedCard: React.FC = ({ refreshInterval = 30_000, assetId = "NGN-XLM", enableWebSocket = true, + isVisible = true, }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -112,25 +120,30 @@ const PriceFeedCard: React.FC = ({ enableDeltaUpdates: true, }); - const load = useCallback(async (manual = false) => { - if (manual) { - setIsRefreshing(true); - start(); - } - setError(null); + const load = useCallback( + async (manual = false) => { + if (manual) { + setIsRefreshing(true); + start(); + } + setError(null); - try { - const feed = await fetchNgnXlmFeed(); - setData(feed); - setLastRefresh(new Date()); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load price feed."); - } finally { - setLoading(false); - setIsRefreshing(false); - if (manual) done(); - } - }, [start, done]); + try { + const feed = await fetchNgnXlmFeed(); + setData(feed); + setLastRefresh(new Date()); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load price feed.", + ); + } finally { + setLoading(false); + setIsRefreshing(false); + if (manual) done(); + } + }, + [start, done], + ); // Handle WebSocket delta updates useEffect(() => { @@ -138,13 +151,19 @@ const PriceFeedCard: React.FC = ({ // Convert WebSocket update to PriceFeedData format const updatedData: PriceFeedData = { price: wsUpdate.price || data?.price || 0, - change_24h: wsUpdate.price ? 0 : (data?.change_24h || 0), // Reset 24h change on new price - high_24h: wsUpdate.price ? Math.max(wsUpdate.price, data?.high_24h || 0) : (data?.high_24h || 0), - low_24h: wsUpdate.price ? Math.min(wsUpdate.price, data?.low_24h || Infinity) : (data?.low_24h || 0), + change_24h: wsUpdate.price ? 0 : data?.change_24h || 0, // Reset 24h change on new price + high_24h: wsUpdate.price + ? Math.max(wsUpdate.price, data?.high_24h || 0) + : data?.high_24h || 0, + low_24h: wsUpdate.price + ? Math.min(wsUpdate.price, data?.low_24h || Infinity) + : data?.low_24h || 0, volume_24h: data?.volume_24h || 0, // Preserve volume from API - last_updated: wsUpdate.timestamp ? new Date(wsUpdate.timestamp).toISOString() : (data?.last_updated || new Date().toISOString()), + last_updated: wsUpdate.timestamp + ? new Date(wsUpdate.timestamp).toISOString() + : data?.last_updated || new Date().toISOString(), }; - + setData(updatedData); setLastRefresh(new Date()); setLoading(false); @@ -161,12 +180,14 @@ const PriceFeedCard: React.FC = ({ // Initial fetch + fallback polling (only when WebSocket is disabled or disconnected) useEffect(() => { + if (!isVisible) return; + if (!enableWebSocket || !isConnected) { load(); const id = setInterval(() => load(), refreshInterval); return () => clearInterval(id); } - }, [load, refreshInterval, enableWebSocket, isConnected]); + }, [load, refreshInterval, enableWebSocket, isConnected, isVisible]); // ── Guardrail: Up/Down arrow is STRICTLY driven by the 24h_change field ── const isUp = data !== null && data.change_24h >= 0; @@ -209,20 +230,28 @@ const PriceFeedCard: React.FC = ({ {/* Live badge + refresh button */}
- + - - + + {enableWebSocket ? (isConnected ? "WS LIVE" : "WS OFF") : "POLLING"} @@ -249,13 +278,19 @@ const PriceFeedCard: React.FC = ({
) : error ? (
-

Feed unavailable

-

{error}

+

+ Feed unavailable +

+

+ {error} +

) : (
{/* Current price */} -
+
{formatPrice(data!.price)}
@@ -332,11 +367,13 @@ const PriceFeedCard: React.FC = ({ {/* ── Footer: last updated ── */}
- {lastRefresh - ? `Updated ${formatTime(lastRefresh.toISOString())}` - : loading - ? - : "—"} + {lastRefresh ? ( + `Updated ${formatTime(lastRefresh.toISOString())}` + ) : loading ? ( + + ) : ( + "—" + )} STELLARFLOW ORACLE diff --git a/src/app/hooks/useIntersectionObserver.js b/src/app/hooks/useIntersectionObserver.js new file mode 100644 index 0000000..ffd729c --- /dev/null +++ b/src/app/hooks/useIntersectionObserver.js @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export default function useIntersectionObserver(options = {}) { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node) return; + + const observer = new IntersectionObserver( + ([entry]) => setIsVisible(entry.isIntersecting), + { threshold: 0.15, ...options } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, []); + + return { ref, isVisible }; +} diff --git a/src/app/page.jsx b/src/app/page.jsx index f9739f6..a55dbc8 100644 --- a/src/app/page.jsx +++ b/src/app/page.jsx @@ -5,22 +5,30 @@ import dynamic from "next/dynamic"; import { motion, AnimatePresence } from "framer-motion"; import { Loader2 } from "lucide-react"; import Nav from "./components/nav"; +import useIntersectionObserver from "./hooks/useIntersectionObserver"; import FloatingSidebar from "./components/FloatingSidebar"; import SystemStats from "./components/SystemStats"; import ModularStatsCard from "./components/ModularStatsCard"; import RelayerStatusTable from "./components/RelayerStatusTable"; import WebSocketTest from "./components/test/WebSocketTest"; -import { Shimmer, MapSkeleton, RateSparklineSkeleton } from "@/components/skeletons"; +import { + Shimmer, + MapSkeleton, + RateSparklineSkeleton, +} from "@/components/skeletons"; const LiveNetworkMap = dynamic(() => import("@/app/components/Map"), { ssr: false, loading: () => , }); -const RateSparklineCard = dynamic(() => import("./components/RateSparklineCard"), { - ssr: false, - loading: () => , -}); +const RateSparklineCard = dynamic( + () => import("./components/RateSparklineCard"), + { + ssr: false, + loading: () => , + }, +); const PriceFeedCard = dynamic(() => import("./components/PriceFeedCard"), { ssr: false, @@ -39,9 +47,24 @@ const mockRelayers = [ // Mock rate cards data const rateCards = [ - { currency: "NGN", rate: 750.5, trend: 2.3, sparklineData: [742, 744, 745, 748, 750, 749, 751] }, - { currency: "USD", rate: 0.12, trend: -0.8, sparklineData: [0.13, 0.13, 0.125, 0.124, 0.123, 0.122, 0.12] }, - { currency: "EUR", rate: 0.13, trend: 1.2, sparklineData: [0.124, 0.125, 0.126, 0.127, 0.128, 0.129, 0.13] }, + { + currency: "NGN", + rate: 750.5, + trend: 2.3, + sparklineData: [742, 744, 745, 748, 750, 749, 751], + }, + { + currency: "USD", + rate: 0.12, + trend: -0.8, + sparklineData: [0.13, 0.13, 0.125, 0.124, 0.123, 0.122, 0.12], + }, + { + currency: "EUR", + rate: 0.13, + trend: 1.2, + sparklineData: [0.124, 0.125, 0.126, 0.127, 0.128, 0.129, 0.13], + }, ]; const LoadingChartState = () => { @@ -50,7 +73,10 @@ const LoadingChartState = () => { export default function DashboardPage() { const [cardsReady, setCardsReady] = useState(false); - + const { ref: priceFeedRef, isVisible: isPriceFeedVisible } = + useIntersectionObserver(); + const { ref: networkMapRef, isVisible: isNetworkMapVisible } = + useIntersectionObserver(); useEffect(() => { const id = requestAnimationFrame(() => setCardsReady(true)); return () => cancelAnimationFrame(id); @@ -61,7 +87,7 @@ export default function DashboardPage() {
); -}; +}