feat: improve homepage betting recommendation UX
This commit is contained in:
@@ -107,6 +107,49 @@ function nextStepText(item: DailyCardItem): string {
|
||||
return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。';
|
||||
}
|
||||
|
||||
function pickMatchKey(item: DailyCardItem): string {
|
||||
return String(item.match_id ?? item.match_label).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function pickMarketGroup(item: DailyCardItem): string {
|
||||
if (item.recommendation === 'SAFE_PARLAY') return '跨場串關';
|
||||
if (item.recommendation === 'SGP_LOTTERY') return '同場串關';
|
||||
if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠';
|
||||
return item.market_type || '單關';
|
||||
}
|
||||
|
||||
function diversifyPicks(items: DailyCardItem[], limit: number): DailyCardItem[] {
|
||||
const selected: DailyCardItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const matchCounts = new Map<string, number>();
|
||||
const matchMarketSeen = new Set<string>();
|
||||
|
||||
const tryAdd = (item: DailyCardItem, maxPerMatch: number, enforceMarketSpread: boolean) => {
|
||||
if (selected.length >= limit) return;
|
||||
const key = pickKey(item);
|
||||
if (seen.has(key)) return;
|
||||
const matchKey = pickMatchKey(item);
|
||||
const marketKey = `${matchKey}|${pickMarketGroup(item)}`;
|
||||
if ((matchCounts.get(matchKey) ?? 0) >= maxPerMatch) return;
|
||||
if (enforceMarketSpread && matchMarketSeen.has(marketKey)) return;
|
||||
|
||||
selected.push(item);
|
||||
seen.add(key);
|
||||
matchCounts.set(matchKey, (matchCounts.get(matchKey) ?? 0) + 1);
|
||||
matchMarketSeen.add(marketKey);
|
||||
};
|
||||
|
||||
items.forEach((item) => tryAdd(item, 2, true));
|
||||
items.forEach((item) => tryAdd(item, 3, false));
|
||||
return selected.slice(0, limit);
|
||||
}
|
||||
|
||||
function shortBetInstruction(item: DailyCardItem): string {
|
||||
if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,把每一腿分開加入;任一腿賠率不達標就整組跳過。';
|
||||
if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用小注碼;若平台沒有同樣分線就不要替代。';
|
||||
return '到投注平台選「單關」,確認同一場、同一市場、同一選項,再比對最低可接受賠率。';
|
||||
}
|
||||
|
||||
function buildGateState(
|
||||
sourceHealth: SourceHealthResponse | null,
|
||||
errors: LoadErrors,
|
||||
@@ -407,7 +450,7 @@ export default function Home() {
|
||||
const balanced = [...singles.slice(0, 3), ...parlays.slice(0, 2), ...sameGame.slice(0, 1), ...highRisk.slice(0, 2)];
|
||||
const used = new Set(balanced.map(pickKey));
|
||||
const remaining = rank(recommendationItems).filter((item) => !used.has(pickKey(item)));
|
||||
return [...balanced, ...remaining].slice(0, 10);
|
||||
return diversifyPicks([...balanced, ...remaining], 10);
|
||||
}, [recommendationItems]);
|
||||
|
||||
const categorySummary = useMemo(() => {
|
||||
@@ -478,7 +521,7 @@ export default function Home() {
|
||||
}
|
||||
const next = [item, ...watchlist].slice(0, 20);
|
||||
setWatchlist(next);
|
||||
setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}`);
|
||||
setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}。最低賠率 ${item.target_odds.toFixed(2)},參考上限 ${formatTwd(stakeAmountTwd(item))}。`);
|
||||
try {
|
||||
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
@@ -506,7 +549,7 @@ export default function Home() {
|
||||
<p className="dot-matrix text-xs font-semibold text-[#b83822]">首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-black text-[#3f2f25] md:text-5xl">下一批可檢查投注候選</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
|
||||
一打開首頁先看這裡:系統會先看今天,若今天已無可下注賽事,就自動切到明天有資料的場次。若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。
|
||||
一打開首頁先看這裡:系統會優先顯示今天與明天,再依日期列出後續可分析賽事。清單會自動分散場次與玩法,避免同一場比賽洗版;若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。
|
||||
</p>
|
||||
<p className="mt-3 rounded-2xl border border-[#eadcb9] bg-white/75 px-4 py-3 text-sm font-bold text-[#5f4330]">
|
||||
目前主顯示日期:<span className="text-[#b83822]">{activeRecommendationDate}</span>
|
||||
@@ -517,6 +560,7 @@ export default function Home() {
|
||||
<span className="rounded-full bg-[#e9f8ef] px-3 py-1 text-[#167a47]">可檢查 {liveRecommendationCount} 組</span>
|
||||
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-[#8a6400]">預掛監控 {watchOnlyRecommendationCount} 組</span>
|
||||
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-[#b83822]">60 秒刷新</span>
|
||||
<span className="rounded-full bg-white px-3 py-1 text-[#5f4330]">首頁最多每場優先顯示 2 組</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -630,6 +674,7 @@ export default function Home() {
|
||||
<p className="mt-1 text-xs font-bold text-[#7a5b46]">台北日期:{activeDateLabel}</p>
|
||||
<p className="mt-2 text-sm font-bold text-[#b83822]">{item.market_type}|{item.selection}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。</p>
|
||||
<p className="mt-2 rounded-2xl border border-[#eadcb9] bg-[#fff8e6] p-3 text-xs font-bold leading-5 text-[#5f4330]">{shortBetInstruction(item)}</p>
|
||||
<div className="mt-4 grid gap-2 text-sm text-[#5f4330]">
|
||||
<p>最低可接受賠率:<span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||||
<p>模型勝率:<span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
@@ -779,6 +824,7 @@ export default function Home() {
|
||||
</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} | {item.selection}</p>
|
||||
<p className="mt-2 rounded-2xl border border-[#eadcb9] bg-white/70 p-3 text-xs font-bold leading-5 text-[#5f4330]">{shortBetInstruction(item)}</p>
|
||||
<div className="mt-4 grid gap-2 text-sm text-[#5f4330] md:grid-cols-3">
|
||||
<p>模型勝率 <span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
<p>最低賠率 <span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||||
|
||||
@@ -48,6 +48,30 @@ function betMode(item: DailyCardItem): string {
|
||||
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',
|
||||
@@ -196,6 +220,14 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa
|
||||
<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>
|
||||
@@ -250,7 +282,7 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa
|
||||
這是研究候選,不是結果承諾。若賠率低於最低可接受賠率、先發名單異常、傷停新聞改變、或資料源延遲,就不要下注,等待下一次刷新。
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議單位。
|
||||
下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議新台幣上限。若你找不到完全相同的玩法,就不要改買相似玩法。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user