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
59 changes: 26 additions & 33 deletions src/components/LiveGameStatsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,6 +15,7 @@ type LiveStatsSnapshot = {

type LiveStatsPayload = Record<string, unknown>;

// 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;
Expand All @@ -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:
Expand All @@ -54,7 +57,6 @@ function getRoundDisplayStatus(isRoundActive: boolean, status?: unknown) {
if (typeof status === "string" && status.trim()) {
return status.replace(/[_-]/g, " ");
}

return isRoundActive ? "live" : "waiting";
}

Expand All @@ -69,15 +71,13 @@ function getConnectionBadge(
dot: "bg-emerald-500",
};
}

if (isSocketConnected || streamStatus === "connecting" || streamStatus === "reconnecting") {
return {
label: streamStatus === "reconnecting" ? "Reconnecting" : "Syncing",
className: "bg-amber-500/15 text-amber-700 ring-amber-500/30 dark:text-amber-300",
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",
Expand All @@ -93,33 +93,29 @@ export default function LiveGameStatsPanel() {
const { isConnected: isSocketConnected } = useConnectionStatus();
const [liveStats, setLiveStats] = useState<LiveStatsSnapshot>({});

// 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,
recentPredictions: (current.recentPredictions ?? 0) + 1,
lastUpdated: new Date(),
}));
});

return () => {
unsubscribeStats();
unsubscribePrediction();
};
}, []);

// Derive values, falling back to round store when live data missing
const inferredActivePlayers =
liveStats.activePlayers ??
toFiniteNumber(activeRound?.activePlayers) ??
Expand All @@ -142,34 +138,22 @@ 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]);

return (
<section className="relative overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-900 p-5 text-white shadow-lg shadow-indigo-950/20 dark:border-indigo-400/20">
<div className="absolute -right-16 -top-20 h-44 w-44 rounded-full bg-cyan-400/20 blur-3xl" aria-hidden="true" />
<div className="absolute -bottom-24 left-8 h-48 w-48 rounded-full bg-fuchsia-500/20 blur-3xl" aria-hidden="true" />

<div className="relative flex flex-col gap-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 flex items-center gap-2 text-xs font-bold uppercase tracking-[0.22em] text-cyan-200">
<Sparkles className="h-4 w-4" aria-hidden="true" />
Live Game Stats
</div>
<h2 className="text-2xl font-black tracking-tight">Platform pulse</h2>
<p className="mt-1 max-w-xl text-sm text-slate-300">{supportingCopy}</p>
</div>

<PanelHeader title="Platform pulse" subtitle={supportingCopy} />
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-bold ring-1 ${connectionBadge.className}`}
aria-label={`Realtime connection status: ${connectionBadge.label}`}
Expand All @@ -178,14 +162,15 @@ export default function LiveGameStatsPanel() {
{connectionBadge.label}
</span>
</div>

<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-white/10 p-4 backdrop-blur">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-300">
<Users className="h-4 w-4 text-cyan-300" aria-hidden="true" />
Active players
</div>
<p className="mt-3 text-3xl font-black">{isLoading ? "…" : formatMetric(inferredActivePlayers)}</p>
<p className="mt-3 text-3xl font-black">
{isLoading ? "…" : formatMetric(inferredActivePlayers)}
</p>
{!isLoading && inferredActivePlayers === undefined && (
<p className="mt-1 text-xs text-slate-400">Awaiting player feed</p>
)}
Expand All @@ -196,7 +181,9 @@ export default function LiveGameStatsPanel() {
<Activity className="h-4 w-4 text-fuchsia-300" aria-hidden="true" />
Recent predictions
</div>
<p className="mt-3 text-3xl font-black">{isLoading ? "…" : formatMetric(inferredPredictions)}</p>
<p className="mt-3 text-3xl font-black">
{isLoading ? "…" : formatMetric(inferredPredictions)}
</p>
{!isLoading && inferredPredictions === undefined && (
<p className="mt-1 text-xs text-slate-400">No prediction count yet</p>
)}
Expand All @@ -207,8 +194,12 @@ export default function LiveGameStatsPanel() {
<Timer className="h-4 w-4 text-emerald-300" aria-hidden="true" />
Round status
</div>
<p className="mt-3 truncate text-2xl font-black capitalize">{isLoading ? "Loading" : roundStatus}</p>
<p className="mt-1 truncate text-xs text-slate-400">Round {hasRound ? `#${activeRound?.id}` : "pending"}</p>
<p className="mt-3 truncate text-2xl font-black capitalize">
{isLoading ? "Loading" : roundStatus}
</p>
<p className="mt-1 truncate text-xs text-slate-400">
Round {hasRound ? `#${activeRound?.id}` : "pending"}
</p>
</div>
</div>

Expand All @@ -224,7 +215,9 @@ export default function LiveGameStatsPanel() {
</span>
</div>
<span className="text-xs text-slate-400">
{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"}
</span>
</div>
</div>
Expand Down
30 changes: 30 additions & 0 deletions src/components/PanelHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="flex flex-col sm:flex-row sm:justify-between gap-3">
<div className="flex flex-col">
<h2 className="text-lg font-bold leading-none text-white">{title}</h2>
{subtitle && <p className="truncate mt-0.5 text-sm text-gray-400">{subtitle}</p>}
</div>
{actions && <div className="self-start sm:self-auto">{actions}</div>}
</header>
);
}
42 changes: 15 additions & 27 deletions src/components/RoundCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
BTC: '₿',
ETH: 'Ξ',
XLM: '✦',
};

interface RoundCardProps {
round: MockRound;
onSubmitPrediction: (round: MockRound) => void;
Expand All @@ -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;
Expand Down Expand Up @@ -78,29 +79,16 @@ export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps)
<header className="flex min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 items-center gap-3">
<span
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#2C4BFD]/15 text-lg font-bold text-[#BEC7FE]"
aria-hidden
className={`self-start rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide sm:self-auto ${
round.mode === "updown"
? "bg-[#2C4BFD]/15 text-[#BEC7FE]"
: "bg-cyan-500/15 text-cyan-300"
}`}
>
{ASSET_ICONS[round.asset]}
{round.mode === "updown" ? "UP/DOWN" : "PRECISION"}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-bold text-white">{round.asset}/USD</h3>
<p className="truncate text-xs text-gray-500">
Reference ${round.startPrice.toLocaleString()}
</p>
</div>
</div>

<span
className={`self-start rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide sm:self-auto ${
round.mode === 'updown'
? 'bg-[#2C4BFD]/15 text-[#BEC7FE]'
: 'bg-cyan-500/15 text-cyan-300'
}`}
>
{round.mode === 'updown' ? 'UP/DOWN' : 'PRECISION'}
</span>
</header>
}
/>

<div
className="flex min-w-0 flex-col gap-2 text-sm text-gray-400 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-3"
Expand Down
4 changes: 1 addition & 3 deletions src/components/StatsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ export default function StatsCard({ stats, isLoading, error, onRetry }: StatsCar

return (
<section className="glass-card rounded-2xl p-5" aria-labelledby="your-stats-title">
<h2 id="your-stats-title" className="text-lg font-bold text-white">
Your Record
</h2>
<PanelHeader title="Your Record" />

<dl className="mt-5 space-y-4">
<div className="flex items-center justify-between">
Expand Down