Files
2026FIFAWorldCup/platform/web/components/ActionableBetCard.tsx
wooo a1f264eee2
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m7s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 5m7s
feat: improve homepage betting recommendation UX
2026-06-18 14:33:08 +08:00

307 lines
16 KiB
TypeScript
Raw Permalink 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 type { DailyCardItem } from '@/lib/analytics-api';
type ActionableBetCardProps = {
item: DailyCardItem;
onAddToSlip: (item: DailyCardItem) => void;
isInSlip?: boolean;
className?: string;
};
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 isReferenceMarket(item: DailyCardItem): boolean {
return item.odds_source_kind === 'reference_market';
}
function recommendationLabel(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 riskLabel(value?: string): string {
if (value === 'core') return '核心候選';
if (value === 'speculative') return '小注高波動';
if (value === 'parlay') return '串關組合';
if (value === 'sgp') return '同場高波動';
return '研究候選';
}
function betMode(item: DailyCardItem): string {
if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,依下方每一腿逐一加入。';
if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用很小的注碼。';
return '到投注平台選「單關」,找到同一場比賽與同一個市場後下注。';
}
function executionSteps(item: DailyCardItem): string[] {
if (item.recommendation === 'SAFE_PARLAY') {
return [
'在投注平台選擇「串關」,不要用單關賠率硬湊。',
'逐一加入下方每一腿,任一腿的場次、玩法、分線或選項不同就整組放棄。',
`總賠率與每腿賠率都要達到門檻,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}`,
];
}
if (item.recommendation === 'SGP_LOTTERY') {
return [
'在投注平台選擇「同場串關」,確認所有選項都來自同一場比賽。',
'平台若沒有完全相同的同場組合或分線,不要用相似選項替代。',
`同場串關波動較高,只能小注,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}`,
];
}
return [
'在投注平台選擇「單關」,找到同一場比賽。',
`選擇「${item.market_type}」裡的「${item.selection}」,並確認分線一致。`,
`只有賠率大於或等於 ${item.target_odds.toFixed(2)} 才考慮,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}`,
];
}
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
maximumFractionDigits: 0,
}).format(value);
}
function stakeAmountTwd(item: DailyCardItem): number {
const unitSize = item.unit_size_twd ?? 1000;
return item.stake_amount_twd ?? Math.round(item.stake_units * unitSize);
}
function stakeGuide(item: DailyCardItem): string {
const unitSize = item.unit_size_twd ?? 1000;
return `建議參考上限 ${formatTwd(stakeAmountTwd(item))},約 ${item.stake_units.toFixed(2)}u系統目前以 1u 等於 ${formatTwd(unitSize)} 換算。`;
}
function oddsRule(item: DailyCardItem): string {
const source = item.odds_source_label ? `目前來源:${item.odds_source_label}` : '';
return `${source}只有在你拿到的賠率大於或等於 ${item.target_odds.toFixed(2)} 時才考慮下注;如果平台賠率低於這個數字,期望值會被吃掉,直接跳過。`;
}
function executionStatusLabel(item: DailyCardItem): string {
if (isReferenceMarket(item)) return '台灣盤參考';
return isConditionalPick(item) ? '預掛條件' : '實盤可檢查';
}
function executionStatusDetail(item: DailyCardItem): string {
if (isReferenceMarket(item)) {
return '這張已有台灣運彩公開盤作為參考價,可以用來比對最低可接受賠率;但它仍不是多莊家正式盤,下注前要再確認同一玩法、同一分線與最新盤口。';
}
if (isConditionalPick(item)) {
return '目前尚未取得完整實盤或分線盤口,這張不是叫你立刻下注,而是先設定最低賠率門檻。等平台開盤後,只有賠率達標才進場。';
}
return '這張已有可比對的盤口資料,仍需在下注前確認同一市場、同一選項、同一分線與最低可接受賠率都一致。';
}
function dataQualityLabel(item: DailyCardItem): string {
if (isReferenceMarket(item)) return '資料品質:台灣運彩參考盤,已和模型門檻比對';
if (item.data_quality === 'rank_elo_prior') return '資料品質:國際排名與實力分數估計,信心已降低';
if (item.data_quality === 'fallback_prior') return '資料品質:基礎估計,僅能觀察';
if (item.data_quality === 'mixed') return '資料品質:串關腿數混合來源';
if (isConditionalPick(item)) return '資料品質:等待實盤確認';
if (item.data_checks?.some((check) => check.includes('去水') || check.includes('莊家'))) return '資料品質:已檢查平台抽成影響';
return '資料品質:基礎盤口檢查';
}
function plainCheckLabel(check: string): string {
return check
.replaceAll('Poisson', '進球分布')
.replaceAll('xG', '預期進球')
.replaceAll('EV', '期望值')
.replaceAll('edge', '模型優勢')
.replaceAll('CLV', '收盤價差')
.replaceAll('FIFA 排名/Elo', '國際排名與實力分數')
.replaceAll('FIFA/Elo', '國際排名與實力分數')
.replaceAll('市場隱含機率', '市場估計機率')
.replaceAll('正 期望值', '期望值為正')
.replaceAll('正期望值', '期望值為正')
.replaceAll('小倉位', '小注碼')
.replaceAll('預掛總賠率門檻', '等待總賠率達標')
.replaceAll('串關 EV 重新計算', '串關期望值重新計算')
.replaceAll('高 期望值 門檻', '高期望值門檻');
}
function plainLanguage(item: DailyCardItem): string {
const implied = typeof item.market_implied_prob === 'number' ? item.market_implied_prob.toFixed(2) : null;
const edge = typeof item.edge_percent === 'number' ? item.edge_percent.toFixed(2) : null;
const isConditional = isConditionalPick(item);
if (implied && edge) {
const quality = item.data_quality && item.data_quality !== 'observed' ? ' 但此項目前資料品質不是完整即時盤,信心與倉位已被系統折扣。' : '';
const sourceNote = isReferenceMarket(item) ? ' 這裡的市場機率來自台灣運彩參考盤,不是多莊家共識。' : '';
if (isConditional) {
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出;目前還沒有完整可驗證賠率,所以 ${implied}% 代表「最低可接受賠率」換算出的門檻,不是真實市場共識。模型多看好 ${edge} 個百分點,期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型條件可能有優勢,不代表這場一定會中。${quality}`;
}
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,市場目前大約只反映 ${implied}% 的機率,等於模型多看好 ${edge} 個百分點。期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型下注的平均報酬為正,不代表這場一定會中。${sourceNote}${quality}`;
}
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,期望值 ${item.ev_percent.toFixed(2)}%。這是長期平均報酬判斷,不是單場保證;若資料品質不是完整即時盤,請只列入觀察。`;
}
export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, className = '' }: ActionableBetCardProps) {
const confidence = typeof item.confidence_score === 'number' ? item.confidence_score : null;
const checks = item.data_checks ?? [];
const confidenceFactors = item.confidence_factors ?? [];
const isCombo = item.legs?.length;
const isConditional = isConditionalPick(item);
const referenceMarket = isReferenceMarket(item);
const actionLabel = isInSlip
? isConditional ? '已在賠率監控清單' : '已在下注前檢查清單'
: isConditional ? '加入賠率監控清單' : '加入下注前檢查清單';
return (
<article className={`group relative overflow-hidden rounded-2xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.12)] transition hover:border-[#d1432d] ${className}`}>
<div className="flex flex-wrap items-center gap-2">
<p className="dot-matrix text-xs font-semibold tracking-wider text-[#b83822]">{recommendationLabel(item)}</p>
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">{riskLabel(item.risk_level)}</span>
<span className={`rounded-full border px-2 py-1 text-[11px] font-semibold ${
isConditional
? 'border-[#b88700]/40 bg-[#fff7d6] text-[#8a6400]'
: referenceMarket
? 'border-[#d8b58c] bg-[#fff0e2] text-[#7d2a15]'
: 'border-[#1a9a57]/30 bg-[#e9f8ef] text-[#167a47]'
}`}>
{executionStatusLabel(item)}
</span>
{item.odds_source_label ? (
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#5f4330]">
{item.odds_source_label}
</span>
) : null}
{confidence !== null ? (
<span className="rounded-full border border-[#1a9a57]/30 bg-[#e9f8ef] px-2 py-1 text-[11px] font-semibold text-[#167a47]">
{confidence.toFixed(1)}
</span>
) : null}
{item.confidence_band ? (
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">
{item.confidence_band}
</span>
) : null}
</div>
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}</p>
<p className="mt-1 text-base font-bold text-[#b83822]">{item.selection}</p>
<section className={`mt-4 rounded-2xl border p-4 ${
isConditional ? 'border-[#e7c462] bg-[#fff7d6]' : 'border-[#9bd8b0] bg-[#e9f8ef]'
}`}>
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{executionStatusDetail(item)}</p>
<p className="mt-2 text-xs font-bold text-[#7a5b46]">{dataQualityLabel(item)}</p>
</section>
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{betMode(item)}</p>
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
<p><span className="font-bold text-[#3f2f25]">{item.market_type}</span></p>
<p><span className="font-bold text-[#3f2f25]">{item.selection}</span></p>
<p><span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p><span className="font-bold text-[#167a47]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<div className="mt-3 rounded-xl border border-[#eadcb9] bg-[#fff8e6] p-3">
<p className="text-xs font-black text-[#7d2a15]"></p>
<ol className="mt-2 space-y-1 text-sm leading-6 text-[#5f4330]">
{executionSteps(item).map((step, index) => (
<li key={step}> {index + 1} {step}</li>
))}
</ol>
</div>
<p className="mt-3 rounded-xl bg-[#fff0e2] p-3 text-sm leading-6 text-[#7d2a15]">{oddsRule(item)}</p>
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">{stakeGuide(item)}</p>
</section>
{isCombo ? (
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<div className="mt-2 space-y-2 text-sm text-[#5f4330]">
{item.legs?.map((leg, index) => (
<p key={`${leg.match_id}-${leg.selection}`}>
{index + 1} <span className="font-bold text-[#3f2f25]">{leg.selection}</span> {leg.odds.toFixed(2)}
</p>
))}
</div>
</section>
) : null}
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-7 text-[#5f4330]">{plainLanguage(item)}</p>
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
<p><span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
{typeof item.market_implied_prob === 'number' ? (
<p>{isConditional ? '賠率門檻換算機率' : '市場估計機率'}<span className="font-bold text-[#3f2f25]">{item.market_implied_prob.toFixed(2)}%</span></p>
) : null}
{typeof item.edge_percent === 'number' ? <p><span className="font-bold text-[#b88700]">{item.edge_percent.toFixed(2)} </span></p> : null}
<p><span className="font-bold text-[#167a47]">{item.ev_percent.toFixed(2)}%</span></p>
</div>
</section>
{confidenceFactors.length || checks.length ? (
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<div className="mt-3 flex flex-wrap gap-2">
{confidenceFactors.slice(0, 5).map((factor) => (
<span key={factor} className="rounded-full bg-[#e9f8ef] px-3 py-1 text-xs font-semibold text-[#167a47]">
{plainCheckLabel(factor)}
</span>
))}
{checks.slice(0, 6).map((check) => (
<span key={check} className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs font-semibold text-[#7a5b46]">
{plainCheckLabel(check)}
</span>
))}
</div>
</section>
) : null}
<section className="mt-4 rounded-2xl border border-[#d8b58c] bg-[#fff0e2] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<button
type="button"
aria-pressed={isInSlip}
className={`mt-4 inline-block w-full rounded-full px-4 py-3 text-sm font-black text-white transition ${
isInSlip ? 'bg-[#167a47] hover:bg-[#11613a]' : 'bg-[#d1432d] hover:bg-[#b83822]'
}`}
onClick={() => onAddToSlip(item)}
>
{actionLabel}
</button>
{isInSlip ? (
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-center text-xs font-bold text-[#167a47]">
</p>
) : null}
</article>
);
}