Files
2026FIFAWorldCup/platform/web/app/daily-card/page.tsx
QuantBot aa7e3bba76
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality & Testing (push) Failing after 1m49s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Rsync (push) Has been skipped
chore: migrate deployment to Gitea Actions with zero-trust rsync
2026-06-16 19:06:50 +08:00

692 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { formatToTaipeiTime } from '@/lib/timezone';
import { getDailyCard, getDailyCardCalendar, type DailyCardCalendarDate, type DailyCardItem, type DailyCardResponse } from '@/lib/analytics-api';
import { ActionableBetCard } from '@/components/ActionableBetCard';
type TabKey = 'all' | 'safe' | 'risk' | 'parlay' | 'sgp';
const TAB_MAP: Record<TabKey, string> = {
all: 'ALL',
safe: 'SAFE_SINGLE',
risk: 'HIGH_RISK_SINGLE',
parlay: 'SAFE_PARLAY',
sgp: 'SGP_LOTTERY',
};
const sampleDate = formatToTaipeiTime(new Date().toISOString(), 'yyyy-MM-dd');
const tomorrowSampleDate = formatToTaipeiTime(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), 'yyyy-MM-dd');
const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist';
const TOURNAMENT_START_DATE = '2026-06-11';
function pickKey(item: DailyCardItem): string {
const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim();
return [
normalize(item.match_id),
normalize(item.market_type),
normalize(item.selection),
].join('|');
}
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
maximumFractionDigits: 0,
}).format(value);
}
function stakeAmountTwd(item: DailyCardItem): number {
return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000));
}
function dailyAmountTwd(card?: DailyCardResponse | null): number {
if (!card) return 0;
return card.total_daily_amount_twd ?? Math.round(card.total_daily_unit_recommendation * (card.unit_size_twd ?? 1000));
}
function itemsFromCard(card?: DailyCardResponse | null): DailyCardItem[] {
if (!card) return [];
return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles];
}
function isConditionalPick(item: DailyCardItem): boolean {
return (
item.has_market_odds === false ||
item.selection.includes('預掛條件') ||
item.selection.includes('參考盤監控') ||
item.rationale.includes('尚未取得可用即時盤口') ||
item.rationale.includes('尚未取得完整實盤賠率') ||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
);
}
function isMissingSnapshotCard(card?: DailyCardResponse | null): boolean {
return card?.market_data_status === 'snapshot_missing_after_kickoff';
}
function snapshotStatusText(value?: string | null): string {
if (value === 'saved') return '已鎖賽前快照';
if (value === 'missing_after_kickoff') return '缺賽前快照';
if (value === 'not_saved_yet') return '等待快照保存';
return '快照狀態待回報';
}
function qualityText(value?: string): string {
if (value === 'rank_elo_prior') return '國際排名與實力分數';
if (value === 'fallback_prior') return '基礎估計';
if (value === 'observed') return '實測/完整模型';
if (value === 'mixed') return '混合來源';
return '待標示';
}
function sourceKindText(item: DailyCardItem): string {
if (item.odds_source_kind === 'reference_market') return '台灣盤參考';
if (item.odds_source_kind === 'multi_provider_market') return '多來源盤';
if (item.odds_source_kind === 'single_provider_market') return '單一來源盤';
if (item.odds_source_kind === 'scoreboard_fallback') return '比分備援盤';
if (item.odds_source_kind === 'conditional_threshold') return '模型門檻';
return item.has_market_odds ? '實盤檢查' : '模型門檻';
}
function sourceExplainText(item: DailyCardItem): string {
if (item.odds_source_kind === 'reference_market') {
return '台灣運彩參考盤可用來比對最低賠率,但不是多莊家正式市場。';
}
if (item.odds_source_kind === 'multi_provider_market') {
return '多個賠率來源可比對,仍需確認同一玩法、同一分線與最新盤口。';
}
if (item.odds_source_kind === 'conditional_threshold') {
return '這是模型推算出的最低進場賠率,平台未達門檻就跳過。';
}
return item.has_market_odds
? '已有盤口可做下注前檢查,但仍要重新核對來源與賠率。'
: '尚未取得該玩法直接盤口,只能先監控。';
}
function plainBackendText(value?: string): string {
if (!value) {
return '';
}
return value
.replaceAll('AI Agent', 'AI 監控')
.replaceAll('AI', 'AI')
.replaceAll('EV', '期望值')
.replaceAll('edge', '模型優勢')
.replaceAll('CLV', '收盤價差')
.replaceAll('xG', '預期進球')
.replaceAll('Poisson', '進球分布')
.replaceAll('FIFA ranking / Elo', '國際排名與實力分數')
.replaceAll('FIFA 排名/Elo', '國際排名與實力分數')
.replaceAll('FIFA/Elo', '國際排名與實力分數')
.replaceAll('market implied', '市場估計')
.replaceAll('市場隱含機率', '市場估計機率')
.replaceAll('小倉位', '小注碼')
.replaceAll('正 EV', '期望值為正')
.replaceAll('正 期望值', '期望值為正')
.replaceAll('高 EV', '高期望值')
.replaceAll('高 期望值', '高期望值')
.replaceAll('edge 與倉位', '模型優勢與注碼');
}
function recommendationText(item: DailyCardItem): string {
const isConditional = isConditionalPick(item);
if (item.recommendation === 'SAFE_SINGLE') return isConditional ? '觀察單關' : '核心單關';
if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠';
if (item.recommendation === 'SAFE_PARLAY') return isConditional ? '觀察串關' : '跨場串關';
if (item.recommendation === 'SGP_LOTTERY') return '同場小注';
return '研究候選';
}
function tabButtonClass(active: boolean): string {
return `rounded-full px-4 py-2 text-sm font-bold transition-all ${
active
? 'bg-[#7d2a15] text-white shadow-[0_10px_26px_rgba(125,42,21,0.20)]'
: 'border border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822] hover:text-[#7d2a15]'
}`;
}
export default function DailyCardPage() {
const [selectedDate, setSelectedDate] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [activeTab, setActiveTab] = useState<TabKey>('all');
const [selectedCount, setSelectedCount] = useState(0);
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
const [feedbackMessage, setFeedbackMessage] = useState('');
const calendarByDate = useMemo(() => {
return new Map(calendarDates.map((item) => [item.date, item]));
}, [calendarDates]);
const chronologicalDates = useMemo(() => {
return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
}, [calendarDates]);
const dateOptions = useMemo(() => {
const pinned = [sampleDate, tomorrowSampleDate].filter((date) => calendarByDate.has(date));
const pinnedSet = new Set(pinned);
return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))];
}, [calendarByDate, chronologicalDates]);
const selectedCard = dailyCards[selectedDate] ?? null;
const selectedSummary = calendarByDate.get(selectedDate);
const selectedMissingSnapshot = isMissingSnapshotCard(selectedCard) || selectedSummary?.market_data_status === 'snapshot_missing_after_kickoff';
const selectedSnapshotStatus = selectedSummary?.snapshot_status
?? (selectedMissingSnapshot ? 'missing_after_kickoff' : selectedCard ? 'not_saved_yet' : null);
const selectedSnapshotItemCount = selectedSummary?.snapshot_item_count ?? 0;
const selectedMatchCount = selectedSummary?.match_count ?? selectedCard?.matched_matches ?? 0;
const dateSummaries = useMemo(() => {
return dateOptions.map((date) => {
const card = dailyCards[date];
const items = itemsFromCard(card);
const summary = calendarByDate.get(date);
return {
date,
matchCount: summary?.match_count ?? card?.matched_matches ?? 0,
recommendationCount: summary?.recommendation_count ?? items.length,
liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length,
marketDataStatus: summary?.market_data_status ?? card?.market_data_status ?? null,
snapshotStatus: summary?.snapshot_status ?? null,
snapshotItemCount: summary?.snapshot_item_count ?? 0,
};
});
}, [calendarByDate, dailyCards, dateOptions]);
const nextAvailableSummary = useMemo(() => {
const future = dateSummaries.filter((item) => item.date > selectedDate && item.recommendationCount > 0);
if (future.length) return future[0];
return dateSummaries.find((item) => item.date !== selectedDate && item.recommendationCount > 0) ?? null;
}, [dateSummaries, selectedDate]);
const tabCards: Record<TabKey, DailyCardItem[]> = useMemo(() => {
if (!selectedCard) {
return { all: [], safe: [], risk: [], parlay: [], sgp: [] };
}
const safe = selectedCard.safe_singles;
const risk = selectedCard.high_risk_singles;
const parlay = selectedCard.safe_parlays;
const sgp = selectedCard.sgp_lotteries;
return {
all: [...safe, ...parlay, ...sgp, ...risk],
safe,
risk,
parlay,
sgp,
};
}, [selectedCard]);
const briefing = useMemo(() => {
if (!selectedCard) {
return '系統載入中,正在運算該日期的賽事、盤口與多玩法投注候選...';
}
if (isMissingSnapshotCard(selectedCard)) {
return `${selectedDate} 已開賽或完賽,但目前沒有可用的賽前投注快照。為了避免事後看答案補造推薦,這天不會產生新的下注建議;請切到下一個有候選的日期,或到賽後校準室看命中率回顧。`;
}
const matched = selectedMatchCount;
const totalAmount = dailyAmountTwd(selectedCard);
const safeCount = selectedCard.safe_singles.length;
const riskCount = selectedCard.high_risk_singles.length;
const allCards = tabCards.all;
const liveCount = allCards.filter((item) => item.has_market_odds).length;
const preMarketCount = allCards.length - liveCount;
return `${selectedDate} 已整理 ${matched} 場賽事,目前有 ${liveCount} 組可做下注前檢查、${preMarketCount} 組只能先放進賠率監控清單。建議參考上限為 ${formatTwd(totalAmount)}${
safeCount > 0
? `其中 ${safeCount} 組是單關候選;如果還沒有真實賠率,只能等待賠率達標後再考慮。`
: '目前沒有足夠漂亮的低風險單關,先保留資金比較合理。'
} ${
riskCount > 0
? `另外 ${riskCount} 組屬於高賠小注候選,波動大,只適合很小的注碼。`
: ''
}`;
}, [selectedCard, selectedDate, selectedMatchCount, tabCards.all]);
const executionMetrics = useMemo(() => {
const allCards = tabCards.all;
const liveCount = allCards.filter((item) => item.has_market_odds).length;
const preMarketCount = allCards.length - liveCount;
const referenceCount = allCards.filter((item) => item.odds_source_kind === 'reference_market').length;
const conditionalCount = allCards.filter((item) => item.odds_source_kind === 'conditional_threshold' || !item.has_market_odds).length;
const qualities = Array.from(new Set(allCards.map((item) => qualityText(item.data_quality))));
const sourceLabels = Array.from(new Set(allCards.map((item) => item.odds_source_label || sourceKindText(item)))).slice(0, 4);
return {
total: allCards.length,
liveCount,
preMarketCount,
referenceCount,
conditionalCount,
qualities: qualities.length ? qualities.join('、') : '尚無推薦',
sourceLabels: sourceLabels.length ? sourceLabels.join('、') : '尚無盤口來源',
refreshSeconds: selectedCard?.auto_refresh_seconds ?? 60,
};
}, [selectedCard, tabCards.all]);
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const calendar = await getDailyCardCalendar(TOURNAMENT_START_DATE);
const groupedDates = calendar.dates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
const pinnedDates = [sampleDate, tomorrowSampleDate].filter((date) => groupedDates.includes(date));
const pinnedSet = new Set(pinnedDates);
const allDates = [...pinnedDates, ...groupedDates.filter((date) => !pinnedSet.has(date))];
const preferredDate = calendar.dates.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date
?? calendar.dates.find((item) => item.date >= sampleDate && item.recommendation_count > 0)?.date
?? pinnedDates[0]
?? allDates.find((date) => date >= sampleDate)
?? allDates[0]
?? sampleDate;
setCalendarDates(calendar.dates);
setSelectedDate((current) => {
if (allDates.includes(current)) return current;
return preferredDate;
});
} catch (payloadError) {
setError(payloadError instanceof Error ? payloadError.message : '無法取得日期推薦摘要資料');
} finally {
setLoading(false);
}
};
load().catch(() => undefined);
const interval = window.setInterval(() => {
load().catch(() => undefined);
}, 60_000);
return () => {
window.clearInterval(interval);
};
}, []);
useEffect(() => {
if (!selectedDate) return undefined;
let mounted = true;
const loadSelectedCard = async () => {
setLoading(true);
setError('');
try {
const card = await getDailyCard(selectedDate);
if (!mounted) return;
setDailyCards((current) => ({ ...current, [selectedDate]: card }));
} catch (payloadError) {
if (!mounted) return;
setError(payloadError instanceof Error ? payloadError.message : '無法取得該日期推薦卡片');
} finally {
if (mounted) setLoading(false);
}
};
loadSelectedCard().catch(() => undefined);
const interval = window.setInterval(() => {
loadSelectedCard().catch(() => undefined);
}, 60_000);
return () => {
mounted = false;
window.clearInterval(interval);
};
}, [selectedDate]);
useEffect(() => {
try {
const raw = window.localStorage.getItem(WATCHLIST_KEY);
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const normalized = parsed.filter(Boolean).slice(0, 20);
setWatchlist(normalized);
setSelectedCount(normalized.length);
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(normalized));
}
} catch {
window.localStorage.removeItem(WATCHLIST_KEY);
}
}, []);
const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]);
function handleAddToSlip(item: DailyCardItem) {
const key = pickKey(item);
const exists = watchlist.some((existing) => pickKey(existing) === key);
if (exists) {
setFeedbackMessage(`這張已在監控清單中:${item.match_label} ${item.selection}`);
return;
}
const next = [item, ...watchlist].slice(0, 20);
setWatchlist(next);
setSelectedCount(next.length);
setFeedbackMessage(`已加入${isConditionalPick(item) ? '賠率監控清單' : '下注前檢查清單'}${item.match_label} ${item.selection}`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} ${item.selection}`);
}
}
function handleRemoveWatchItem(item: DailyCardItem) {
const next = watchlist.filter((existing) => pickKey(existing) !== pickKey(item));
setWatchlist(next);
setSelectedCount(next.length);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。');
}
}
function handleClearWatchlist() {
setWatchlist([]);
setSelectedCount(0);
try {
window.localStorage.removeItem(WATCHLIST_KEY);
} catch {
setFeedbackMessage('已清空本頁清單;瀏覽器永久保存狀態稍後會在重新整理後同步。');
}
}
return (
<div className="space-y-6">
<section className="panel-glow rounded-2xl p-6">
<h2 className="dot-matrix text-2xl font-bold text-[#7d2a15]"></h2>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
<div className="mt-4 grid gap-3 md:grid-cols-4">
{[
{ href: '/source-health', step: '1', title: '先看資料健康', detail: '確認賽程、比分、新聞與盤口是否新鮮。' },
{ href: '/recommendation-readiness', step: '2', title: '再看推薦閘門', detail: '確認現在能不能用正式推薦語氣。' },
{ href: '/market-coverage', step: '3', title: '檢查盤口覆蓋', detail: '確認玩法是否真的有盤、有來源。' },
{ href: '/daily-card', step: '4', title: '最後挑候選', detail: '只挑賠率達標且注碼可控的卡片。' },
].map((item) => (
<Link key={item.step} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822]">
<div className="flex gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{item.step}</span>
<div>
<p className="font-bold text-[#3f2f25]">{item.title}</p>
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
</div>
</div>
</Link>
))}
</div>
<div className="mt-5 rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
0
</p>
</div>
<p className="text-xs font-bold text-[#8a6b58]"> / </p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{dateSummaries.map((item) => {
const isPinned = item.date === sampleDate || item.date === tomorrowSampleDate;
const missingSnapshot = item.marketDataStatus === 'snapshot_missing_after_kickoff';
return (
<button
key={item.date}
type="button"
onClick={() => {
setSelectedDate(item.date);
setActiveTab('all');
}}
className={`rounded-full border px-4 py-2 text-left text-xs font-black transition ${
selectedDate === item.date
? 'border-[#7d2a15] bg-[#7d2a15] text-white shadow-[0_10px_26px_rgba(125,42,21,0.20)]'
: 'border-[#d8b58c] bg-[#fff8e6] text-[#5f4330] hover:border-[#b83822]'
}`}
>
<span>{item.date}</span>
{isPinned ? <span className="ml-1 opacity-80">{item.date === sampleDate ? '今天' : '明天'}</span> : null}
<span className="ml-2 opacity-90">{item.matchCount} / {item.recommendationCount} </span>
{missingSnapshot ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{!missingSnapshot && item.snapshotStatus === 'saved' ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{!missingSnapshot && item.snapshotStatus === 'not_saved_yet' ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{!missingSnapshot && item.recommendationCount > 0 && item.liveCount <= 0 ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{item.liveCount > 0 ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">{item.liveCount} </span> : null}
</button>
);
})}
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-[#7a5b46]">{selectedDate} {selectedMatchCount} {tabCards.all.length} </p>
<p className="text-sm font-semibold text-[#167a47]">
{selectedCard ? formatTwd(dailyAmountTwd(selectedCard)) : '-'}
</p>
</div>
<div className="mt-4 rounded-xl border border-[#eadcb9] bg-white/70 p-4">
<p className="text-sm leading-6 text-[#5f4330]">{briefing}</p>
{selectedCard?.summary ? <p className="mt-3 text-xs leading-5 text-[#7a5b46]">{plainBackendText(selectedCard.summary)}</p> : null}
{selectedCard?.execution_policy ? <p className="mt-3 text-xs leading-5 text-[#8a6400]">{plainBackendText(selectedCard.execution_policy)}</p> : null}
</div>
{selectedMissingSnapshot ? (
<div className="mt-4 rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="dot-matrix text-sm font-black text-[#b83822]"></p>
<h3 className="mt-2 text-xl font-black text-[#7d2a15]"></h3>
<p className="mt-2 text-sm leading-6 text-[#6f4f3c]">
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row md:flex-col">
{nextAvailableSummary ? (
<button
type="button"
className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]"
onClick={() => {
setSelectedDate(nextAvailableSummary.date);
setActiveTab('all');
}}
>
{nextAvailableSummary.date} {nextAvailableSummary.recommendationCount}
</button>
) : null}
<Link href="/recommendation-performance" className="rounded-full border border-[#d8b58c] bg-white/75 px-5 py-3 text-center text-sm font-bold text-[#7d2a15] transition hover:border-[#b83822]">
</Link>
</div>
</div>
</div>
) : null}
<div className="mt-4 grid gap-3 md:grid-cols-5">
{[
{ label: '實盤可檢查', value: executionMetrics.liveCount, helper: '可進入下注前檢查' },
{ label: '預掛觀察', value: executionMetrics.preMarketCount, helper: '只加入賠率監控' },
{ label: '台灣盤參考', value: executionMetrics.referenceCount, helper: '可比對最低賠率' },
{ label: '資料品質', value: executionMetrics.qualities, helper: '目前模型來源' },
{ label: '賽前快照', value: snapshotStatusText(selectedSnapshotStatus), helper: selectedSnapshotItemCount ? `已保存 ${selectedSnapshotItemCount}` : '保存狀態' },
].map((item) => (
<article key={item.label} className="rounded-xl border border-[#eadcb9] bg-white/70 p-4">
<p className="text-xs text-[#8a6b58]">{item.helper}</p>
<p className="mt-2 text-lg font-black text-[#7d2a15]">{item.value}</p>
<p className="mt-1 text-xs text-[#7a5b46]">{item.label}</p>
</article>
))}
</div>
<div className="mt-4 rounded-xl border border-[#eadcb9] bg-white/70 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
{executionMetrics.sourceLabels}
</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
</p>
</article>
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">AI </p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
AI
</p>
</article>
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
</p>
</article>
</div>
<div className="mt-4 flex items-center gap-2">
<div className={`status-led ${watchlist.length > 0 ? 'status-led-ok' : 'status-led-warn'}`} />
<p className="text-xs text-[#7a5b46]">{watchlist.length || selectedCount} </p>
</div>
{feedbackMessage ? (
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
{feedbackMessage}
</p>
) : null}
</section>
<section className="panel-glow rounded-2xl p-5">
<p className="dot-matrix text-lg font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className={tabButtonClass(activeTab === 'all')}
onClick={() => setActiveTab('all')}
>
</button>
<button
type="button"
className={tabButtonClass(activeTab === 'safe')}
onClick={() => setActiveTab('safe')}
>
</button>
<button
type="button"
className={tabButtonClass(activeTab === 'risk')}
onClick={() => setActiveTab('risk')}
>
</button>
<button
type="button"
className={tabButtonClass(activeTab === 'parlay')}
onClick={() => setActiveTab('parlay')}
>
</button>
<button
type="button"
className={tabButtonClass(activeTab === 'sgp')}
onClick={() => setActiveTab('sgp')}
>
</button>
</div>
</section>
<section className="panel-glow rounded-2xl p-5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="dot-matrix text-lg font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
</div>
{watchlist.length > 0 ? (
<button
type="button"
className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15] transition hover:border-[#b83822] hover:text-[#b83822]"
onClick={handleClearWatchlist}
>
</button>
) : null}
</div>
{watchlist.length ? (
<div className="mt-4 grid gap-3 md:grid-cols-2">
{watchlist.map((item) => (
<article key={pickKey(item)} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
isConditionalPick(item) ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'
}`}>
{isConditionalPick(item) ? '等待賠率達標' : '實盤檢查'}
</span>
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7d2a15]">{recommendationText(item)}</span>
<span className="rounded-full border border-[#d8b58c] bg-white px-3 py-1 text-xs font-bold text-[#5f4330]">
{item.odds_source_label || sourceKindText(item)}
</span>
</div>
<p className="mt-3 text-base font-black text-[#3f2f25]">{item.match_label}</p>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}{item.selection}</p>
<p className="mt-2 rounded-xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#7a5b46]">{sourceExplainText(item)}</p>
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-4">
<p> <span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p> <span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
<p> <span className="font-bold text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : '-'}</span></p>
<p> <span className="font-bold text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<button
type="button"
className="mt-3 text-xs font-semibold text-[#7a5b46] transition hover:text-[#b83822]"
onClick={() => handleRemoveWatchItem(item)}
>
</button>
</article>
))}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-white/55 p-5 text-sm leading-6 text-[#7a5b46]">
</div>
)}
</section>
{loading ? <p className="text-sm text-[#7a5b46] dot-matrix dot-matrix-loading">...</p> : null}
{error ? <p className="text-sm text-red-400">{error}</p> : null}
<section className="grid gap-4 md:grid-cols-2">
{tabCards[activeTab].map((item) => (
<ActionableBetCard
key={`${item.match_id}-${item.selection}-${item.market_type}`}
item={item}
onAddToSlip={handleAddToSlip}
isInSlip={watchlistKeys.has(pickKey(item))}
className="panel-glow"
/>
))}
{!loading && tabCards[activeTab].length === 0 ? (
<div className="panel-glow rounded-2xl p-6 text-center border-dashed">
<p className="text-sm text-[#7a5b46]">
{selectedMissingSnapshot
? '此日期缺少賽前快照,因此不顯示事後補造的投注推薦。請切到下一個有候選的日期,或查看賽後校準。'
: '這個分類目前沒有達標候選。沒有足夠資料或賠率不漂亮時,系統不會硬湊推薦。'}
</p>
</div>
) : null}
</section>
</div>
);
}