115 lines
5.1 KiB
TypeScript
115 lines
5.1 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { OddsLineMovementChart } from '@/components/OddsLineMovementChart';
|
||
import { getAllMatches, getMatchById, type MatchListItem, type MatchDetail } from '@/lib/analytics-api';
|
||
|
||
export default function OddsPage() {
|
||
const [rows, setRows] = useState<{ match: MatchListItem; detail: MatchDetail | null }[]>([]);
|
||
const [selectedMatch, setSelectedMatch] = useState<MatchDetail | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
try {
|
||
const list = await getAllMatches();
|
||
const now = Date.now();
|
||
const upcoming = list
|
||
.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 120 * 60 * 1000 && match.status !== '已結束')
|
||
.slice(0, 5);
|
||
const hydrated = await Promise.all(
|
||
upcoming.map(async (match) => {
|
||
try {
|
||
return { match, detail: await getMatchById(match.match_id) };
|
||
} catch {
|
||
return { match, detail: null };
|
||
}
|
||
}),
|
||
);
|
||
setRows(hydrated);
|
||
setSelectedMatch(hydrated.find((row) => row.detail?.odds_series.length)?.detail ?? hydrated[0]?.detail ?? null);
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
load();
|
||
const interval = window.setInterval(load, 60_000);
|
||
return () => window.clearInterval(interval);
|
||
}, []);
|
||
|
||
const chartData = selectedMatch?.odds_series.map(point => ({
|
||
time: new Date(point.recorded_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }),
|
||
bookmaker: point.bookmaker,
|
||
odds: point.decimal_odds,
|
||
})) || [];
|
||
|
||
function latestBookOdds(detail: MatchDetail | null): string[] {
|
||
if (!detail?.odds_series.length) return [];
|
||
const latest = [...detail.odds_series].sort((a, b) => new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime());
|
||
const seen = new Set<string>();
|
||
const values: string[] = [];
|
||
for (const point of latest) {
|
||
const key = `${point.bookmaker}-${point.market_type}-${point.selection}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
values.push(`${point.bookmaker}|${point.market_type} ${point.selection} ${point.decimal_odds.toFixed(2)}`);
|
||
if (values.length === 3) break;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h2 className="dot-matrix text-2xl font-bold text-cyan-400">跨平台市場指數比較矩陣</h2>
|
||
<section className="panel-glow rounded-2xl p-6">
|
||
<p className="text-sm text-slate-300 mb-4">此模組匯聚全球頂級機構即時定價,透過動態比對尋找潛在無風險套利空間與定價偏差。</p>
|
||
|
||
{loading ? (
|
||
<p className="text-sm text-slate-400 dot-matrix dot-matrix-loading">同步即時定價庫中...</p>
|
||
) : (
|
||
<div className="overflow-auto rounded-xl border border-slate-700 bg-slate-900/50">
|
||
<table className="min-w-full text-sm text-left">
|
||
<thead>
|
||
<tr className="bg-slate-800 text-slate-200">
|
||
<th className="px-4 py-3 border-b border-slate-700">賽事</th>
|
||
<th className="px-4 py-3 border-b border-slate-700">最新盤口 1</th>
|
||
<th className="px-4 py-3 border-b border-slate-700">最新盤口 2</th>
|
||
<th className="px-4 py-3 border-b border-slate-700">最新盤口 3</th>
|
||
<th className="px-4 py-3 border-b border-slate-700">市場有效性</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-700">
|
||
{rows.map(({ match, detail }) => {
|
||
const odds = latestBookOdds(detail);
|
||
return (
|
||
<tr key={match.match_id} className="hover:bg-slate-800/50 transition">
|
||
<td className="px-4 py-3 text-slate-100">{match.home_team} vs {match.away_team}</td>
|
||
<td className="px-4 py-3 text-cyan-400">{odds[0] ?? '等待盤口'}</td>
|
||
<td className="px-4 py-3 text-cyan-400">{odds[1] ?? '等待盤口'}</td>
|
||
<td className="px-4 py-3 text-cyan-400">{odds[2] ?? '等待盤口'}</td>
|
||
<td className={odds.length ? 'px-4 py-3 text-emerald-400' : 'px-4 py-3 text-amber-300'}>
|
||
{odds.length ? '已有可檢查盤口' : '等待資料源'}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{selectedMatch && chartData.length > 0 && (
|
||
<OddsLineMovementChart data={chartData} teamName={selectedMatch.home_team} />
|
||
)}
|
||
{!loading && !rows.some((row) => row.detail?.odds_series.length) ? (
|
||
<section className="panel-glow rounded-2xl border-dashed p-5 text-sm text-slate-400">
|
||
目前沒有可驗證的即時賠率序列;系統不再以固定 1.90 類數字假裝跨平台比較。
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|