diff --git a/src/components/LiveGameStatsPanel.tsx b/src/components/LiveGameStatsPanel.tsx index 163f675..ed178fc 100644 --- a/src/components/LiveGameStatsPanel.tsx +++ b/src/components/LiveGameStatsPanel.tsx @@ -1,9 +1,12 @@ +// LiveGameStatsPanel component – cleaned up (no duplicate utilities) import { useEffect, useMemo, useState } from "react"; -import { Activity, Radio, ShieldAlert, Sparkles, Timer, Users } from "lucide-react"; +import { Activity, Radio, ShieldAlert, Timer, Users } from "lucide-react"; import { socketService } from "../lib/socket"; import { useConnectionStatus } from "../hooks/useConnectionStatus"; import { useRoundStore } from "../store/useRoundStore"; +import PanelHeader from "./PanelHeader"; +// Types type LiveStatsSnapshot = { activePlayers?: number; recentPredictions?: number; @@ -12,6 +15,7 @@ type LiveStatsSnapshot = { type LiveStatsPayload = Record; +// Helper utilities – single definition function toFiniteNumber(value: unknown): number | undefined { const parsed = typeof value === "string" ? Number(value) : value; return typeof parsed === "number" && Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined; @@ -21,14 +25,13 @@ function normalizeStatsPayload(payload: unknown): LiveStatsSnapshot { if (!payload || typeof payload !== "object") { return {}; } - const data = payload as LiveStatsPayload; const nested = data.stats && typeof data.stats === "object" ? (data.stats as LiveStatsPayload) : data.data && typeof data.data === "object" - ? (data.data as LiveStatsPayload) - : data; + ? (data.data as LiveStatsPayload) + : data; return { activePlayers: @@ -54,7 +57,6 @@ function getRoundDisplayStatus(isRoundActive: boolean, status?: unknown) { if (typeof status === "string" && status.trim()) { return status.replace(/[_-]/g, " "); } - return isRoundActive ? "live" : "waiting"; } @@ -69,7 +71,6 @@ function getConnectionBadge( dot: "bg-emerald-500", }; } - if (isSocketConnected || streamStatus === "connecting" || streamStatus === "reconnecting") { return { label: streamStatus === "reconnecting" ? "Reconnecting" : "Syncing", @@ -77,7 +78,6 @@ function getConnectionBadge( dot: "bg-amber-400 animate-pulse", }; } - return { label: "Offline", className: "bg-rose-500/15 text-rose-700 ring-rose-500/30 dark:text-rose-300", @@ -93,19 +93,15 @@ export default function LiveGameStatsPanel() { const { isConnected: isSocketConnected } = useConnectionStatus(); const [liveStats, setLiveStats] = useState({}); + // Subscribe to live stats via socket useEffect(() => { if (!socketService.isConnected()) { socketService.connect(); } - const unsubscribeStats = socketService.onLiveGameStats((payload) => { const snapshot = normalizeStatsPayload(payload); - setLiveStats((current) => ({ - ...current, - ...snapshot, - })); + setLiveStats((current) => ({ ...current, ...snapshot })); }); - const unsubscribePrediction = socketService.onPredictionCreated(() => { setLiveStats((current) => ({ ...current, @@ -113,13 +109,13 @@ export default function LiveGameStatsPanel() { lastUpdated: new Date(), })); }); - return () => { unsubscribeStats(); unsubscribePrediction(); }; }, []); + // Derive values, falling back to round store when live data missing const inferredActivePlayers = liveStats.activePlayers ?? toFiniteNumber(activeRound?.activePlayers) ?? @@ -142,15 +138,12 @@ export default function LiveGameStatsPanel() { if (isLoading) { return "Loading the latest game telemetry…"; } - if (!isSocketConnected && sseConnection?.status !== "connected") { return "Realtime updates are paused. Showing the latest available snapshot."; } - if (!hasRound) { return "No active round is broadcasting yet. Stay ready for the next launch."; } - return "Live telemetry refreshes as players join rounds and predictions land."; }, [hasRound, isLoading, isSocketConnected, sseConnection?.status]); @@ -158,18 +151,9 @@ export default function LiveGameStatsPanel() {
- {liveStats.lastUpdated ? `Updated ${liveStats.lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "Listening for live events"} + {liveStats.lastUpdated + ? `Updated ${liveStats.lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : "Listening for live events"} diff --git a/src/components/PanelHeader.tsx b/src/components/PanelHeader.tsx new file mode 100644 index 0000000..0ea95da --- /dev/null +++ b/src/components/PanelHeader.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; + +interface PanelHeaderProps { + /** Main title displayed prominently */ + title: string; + /** Optional subtitle rendered smaller beneath the title */ + subtitle?: string; + /** Optional slot for action elements such as buttons or menus */ + actions?: ReactNode; +} + +/** + * Reusable panel header component used across the Xelma-Frontend UI. + * + * The layout mirrors the previous ad‑hoc headers: + * - Small screens stack title/subtitle and actions vertically (`flex-col`). + * - Medium screens (`sm:`) place them side‑by‑side (`flex-row`) and justify space between. + * - Consistent typography and spacing are applied via Tailwind utilities. + */ +export default function PanelHeader({ title, subtitle, actions }: PanelHeaderProps) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/components/RoundCard.tsx b/src/components/RoundCard.tsx index ad084c6..d82957a 100644 --- a/src/components/RoundCard.tsx +++ b/src/components/RoundCard.tsx @@ -4,16 +4,14 @@ import { useEffect, useRef, useState } from 'react'; import type { MockRound } from '../types'; +import { useState } from "react"; + + import CountdownTimer from './CountdownTimer'; +import PanelHeader from './PanelHeader'; import { formatVXLM, formatPercent } from '../lib/utils'; import { TRANSITION } from '../utils/motion'; -const ASSET_ICONS: Record = { - BTC: '₿', - ETH: 'Ξ', - XLM: '✦', -}; - interface RoundCardProps { round: MockRound; onSubmitPrediction: (round: MockRound) => void; @@ -37,7 +35,10 @@ function poolSize(round: MockRound): number { } export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps) { + + const [endTime] = useState(() => Date.now() + round.closesInSeconds * 1000); const total = poolSize(round); + const upRatio = round.mode === 'updown' && total > 0 ? (round.poolUp ?? 0) / total : 0; const upPct = Math.round(upRatio * 100); const downPct = round.mode === 'updown' ? 100 - upPct : 0; @@ -78,29 +79,16 @@ export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps)
- {ASSET_ICONS[round.asset]} + {round.mode === "updown" ? "UP/DOWN" : "PRECISION"} -
-

{round.asset}/USD

-

- Reference ${round.startPrice.toLocaleString()} -

-
-
- - - {round.mode === 'updown' ? 'UP/DOWN' : 'PRECISION'} - -
+ } + />
-

- Your Record -

+