664 lines
25 KiB
TypeScript
664 lines
25 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { outcomeLabel, pctParts, kickoffDisplay, winProb } from "@/lib/format";
|
||
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||
import type {
|
||
Outcome,
|
||
CrowdStats,
|
||
ModelName,
|
||
Match,
|
||
AIPrediction,
|
||
MyPrediction,
|
||
Team,
|
||
} from "@/lib/types";
|
||
import {
|
||
ApiError,
|
||
fetchMyPredictions,
|
||
getDeviceId,
|
||
getRecentEmails,
|
||
rememberEmail,
|
||
submitPrediction,
|
||
} from "@/lib/api";
|
||
import Countdown from "./Countdown";
|
||
import RankingBoard from "./RankingBoard";
|
||
import TeamFlag from "./TeamFlag";
|
||
|
||
type Step = "form" | "done";
|
||
|
||
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
|
||
function fmtSubmitted(iso: string): string {
|
||
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
|
||
return m ? `${+m[1]}.${+m[2]}` : "";
|
||
}
|
||
|
||
const MODEL_ICON: Record<ModelName, string> = {
|
||
GPT: "/icons/gpt.png",
|
||
Claude: "/icons/claude.jpg",
|
||
Gemini: "/icons/gemini.jpeg",
|
||
};
|
||
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
export default function Arena({
|
||
match,
|
||
predictions,
|
||
crowd: initialCrowd,
|
||
shareUrl,
|
||
lang = "ko",
|
||
}: {
|
||
match: Match;
|
||
predictions: AIPrediction[];
|
||
crowd: CrowdStats;
|
||
shareUrl: string;
|
||
lang?: Lang;
|
||
}) {
|
||
const t = dict(lang);
|
||
const aShort = teamShort(match.teamA, lang);
|
||
const bShort = teamShort(match.teamB, lang);
|
||
// 한국을 항상 왼쪽에 표시(요청). 상태·투표 제출은 원본 A/B 프레임 그대로, 화면 좌우만 교체.
|
||
const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR";
|
||
const leftShort = flip ? bShort : aShort;
|
||
const rightShort = flip ? aShort : bShort;
|
||
const [outcome, setOutcome] = useState<Outcome | null>("DRAW");
|
||
const [scoreA, setScoreA] = useState(0);
|
||
const [scoreB, setScoreB] = useState(0);
|
||
const [step, setStep] = useState<Step>("form");
|
||
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
|
||
const [email, setEmail] = useState(""); // 칸은 비우고, 입력/포커스 시 datalist 드롭다운으로만 자동완성
|
||
const [notify, setNotify] = useState(true);
|
||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||
const [copied, setCopied] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showRules, setShowRules] = useState(false);
|
||
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
|
||
const [votesExpanded, setVotesExpanded] = useState(false);
|
||
|
||
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||
useEffect(() => {
|
||
setOutcome(
|
||
scoreA > scoreB ? "TEAM_A_WIN" : scoreA < scoreB ? "TEAM_B_WIN" : "DRAW",
|
||
);
|
||
}, [scoreA, scoreB]);
|
||
|
||
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
|
||
const finished = !!match.result;
|
||
const disabled = finished || !match.votingOpen;
|
||
const ctaLabel = finished
|
||
? t.ctaResult
|
||
: match.phase === "live"
|
||
? t.ctaLive
|
||
: match.phase === "locked"
|
||
? t.ctaLocked
|
||
: match.phase === "scheduled" && !match.votingOpen
|
||
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
|
||
: t.ctaBeat;
|
||
|
||
const matched = useMemo(() => {
|
||
if (!outcome) return [];
|
||
return predictions
|
||
.filter((p) => p.outcome === outcome)
|
||
.map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB }));
|
||
}, [outcome, scoreA, scoreB, predictions]);
|
||
|
||
const emailValid = EMAIL_RE.test(email.trim());
|
||
|
||
const confirmSubmit = async () => {
|
||
if (!EMAIL_RE.test(email.trim()) || !outcome) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
const res = await submitPrediction({
|
||
matchId: match.matchId,
|
||
deviceId: getDeviceId(),
|
||
outcome,
|
||
scoreA,
|
||
scoreB,
|
||
email: email.trim(),
|
||
notify,
|
||
});
|
||
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
||
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
|
||
setRecentEmails(getRecentEmails());
|
||
// 방금 제출 포함해 내 지난 예측 갱신(이번 경기 강조/upsert 반영)
|
||
fetchMyPredictions(email.trim()).then(setMyPreds).catch(() => {});
|
||
setStep("done");
|
||
} catch (e) {
|
||
const msg =
|
||
e instanceof ApiError
|
||
? e.status === 423
|
||
? t.ctaLocked
|
||
: e.detail
|
||
: lang === "en"
|
||
? "Submission failed. Please try again."
|
||
: "제출에 실패했습니다. 다시 시도해주세요.";
|
||
setError(msg);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const shareText = useMemo(() => {
|
||
if (!outcome) return "";
|
||
const me = `${leftShort} ${flip ? scoreB : scoreA}-${flip ? scoreA : scoreB} ${rightShort}`;
|
||
const sameAI = matched.map((m) => m.model).join("·");
|
||
if (lang === "en") {
|
||
return sameAI
|
||
? `I picked ${me}, same as ${sameAI}! You? — TriplePick`
|
||
: `I picked ${me} — different from all 3 AIs! You? — TriplePick`;
|
||
}
|
||
return sameAI
|
||
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
|
||
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
||
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
|
||
|
||
const copyLink = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1800);
|
||
} catch {
|
||
setCopied(false);
|
||
}
|
||
};
|
||
const nativeShare = async () => {
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({
|
||
title: `${aShort} vs ${bShort} — TriplePick`,
|
||
text: shareText,
|
||
url: shareUrl,
|
||
});
|
||
} catch {
|
||
/* cancelled */
|
||
}
|
||
} else copyLink();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
|
||
{!finished && match.votingOpen && (
|
||
<div className="mt-4">
|
||
<Countdown to={match.lockAt} lang={lang} />
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
||
<section className="sheet mt-4 p-5 text-[var(--ink)]">
|
||
<div className="mb-3">
|
||
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{predictions.map((p) => {
|
||
const wp = winProb(match, p);
|
||
const wpLabel = wp.team
|
||
? t.winProb(teamShort(wp.team, lang))
|
||
: t.drawOdds;
|
||
return (
|
||
<div
|
||
key={p.model}
|
||
className="rounded-2xl border border-[var(--line-l)] bg-[var(--sheet-card)] p-4"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={MODEL_ICON[p.model]}
|
||
alt={p.model}
|
||
className="h-12 w-12 shrink-0 rounded-full object-cover"
|
||
/>
|
||
<div className="min-w-0">
|
||
<div className="text-[20px] font-extrabold leading-none">
|
||
{p.model}
|
||
</div>
|
||
{/* D3: 승리 예측 팀의 승리 확률 + 승리팀 국기 */}
|
||
<div className="mt-1.5 flex items-center gap-1.5">
|
||
{wp.team && (
|
||
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
|
||
)}
|
||
<span className="text-[12px] font-bold text-[var(--ink-muted)]">
|
||
{wpLabel} <span className="text-[var(--gpt)]">{wp.pct}%</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-auto shrink-0 whitespace-nowrap font-mono text-[28px] font-extrabold tabular-nums">
|
||
{flip ? p.scoreB : p.scoreA} - {flip ? p.scoreA : p.scoreB}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 메인 리즌 강조 (D3: 확률은 텍스트, 바 제거) */}
|
||
<p className="mt-3 text-[15px] font-bold leading-snug text-[var(--ink)]">
|
||
“{p.reasonShort}”
|
||
</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 당신의 선택 */}
|
||
<div className="mt-5 flex items-center justify-between">
|
||
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
|
||
{t.yourPickLabel}
|
||
</span>
|
||
<span className="text-[20px] font-extrabold">{t.yourChoice}</span>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
{(
|
||
(flip
|
||
? [
|
||
["TEAM_B_WIN", `${bShort} ${t.win}`],
|
||
["DRAW", t.drawLabel],
|
||
["TEAM_A_WIN", `${aShort} ${t.win}`],
|
||
]
|
||
: [
|
||
["TEAM_A_WIN", `${aShort} ${t.win}`],
|
||
["DRAW", t.drawLabel],
|
||
["TEAM_B_WIN", `${bShort} ${t.win}`],
|
||
]) as [Outcome, string][]
|
||
).map(([val, label]) => (
|
||
<button
|
||
key={val}
|
||
disabled={disabled}
|
||
onClick={() => setOutcome(val)}
|
||
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition disabled:opacity-50 ${
|
||
outcome === val
|
||
? "border-transparent bg-[var(--mint)] text-[var(--mint-ink)]"
|
||
: "border-[var(--line-l)] bg-white text-[var(--ink-muted)]"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
|
||
{flip ? (
|
||
<>
|
||
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} />
|
||
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
|
||
<Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} />
|
||
</>
|
||
) : (
|
||
<>
|
||
<Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} />
|
||
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
|
||
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ===== 종료된 경기: 결과 보기 ===== */}
|
||
{finished && match.result && (
|
||
<section className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
|
||
<div className="text-[13px] text-[var(--ink-muted)]">{t.finalResult}</div>
|
||
<div className="mt-1 text-[26px] font-extrabold">
|
||
{leftShort} {flip ? match.result.scoreB : match.result.scoreA}-{flip ? match.result.scoreA : match.result.scoreB} {rightShort}
|
||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||
{outcomeLabel(match, match.result.outcome, lang)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 flex flex-col gap-1.5">
|
||
{predictions.map((p) => {
|
||
const r = match.result!;
|
||
const exact = p.scoreA === r.scoreA && p.scoreB === r.scoreB;
|
||
const outcomeHit = p.outcome === r.outcome;
|
||
// 3단계: 정확(스코어까지) / 적중(승패만) / 빗나감
|
||
const label = exact ? t.exactHit : outcomeHit ? t.hit : t.miss;
|
||
const cls = exact
|
||
? "rounded bg-[var(--green)]/15 px-1.5 py-0.5 font-extrabold text-[var(--green)]"
|
||
: outcomeHit
|
||
? "font-bold text-[var(--green)]"
|
||
: "text-white/45";
|
||
return (
|
||
<div key={p.model} className="flex items-center justify-between text-[13px]">
|
||
<span className="font-bold text-white/85">{p.model}</span>
|
||
<span className={cls}>{label}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ===== 투표 마감/오픈 전: 상태 버튼만 ===== */}
|
||
{!finished && disabled && (
|
||
<button
|
||
disabled
|
||
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold opacity-60"
|
||
>
|
||
{ctaLabel}
|
||
</button>
|
||
)}
|
||
|
||
{/* ===== 투표 제출 (D5): 이메일 + 단일 CTA를 한 카드로 통합 ===== */}
|
||
{!finished && !disabled && step === "form" && (
|
||
<div className="mt-5 rounded-2xl border-2 border-[var(--green)]/60 bg-[var(--bg2)] p-4 shadow-[0_0_24px_rgba(74,255,160,0.08)]">
|
||
<div className="mb-2.5 text-center text-[15px] font-extrabold text-[var(--green)]">
|
||
{t.emailGate}
|
||
</div>
|
||
<input
|
||
type="email"
|
||
inputMode="email"
|
||
list="tp-email-suggestions"
|
||
autoComplete="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder={t.emailPh}
|
||
className="w-full rounded-2xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]"
|
||
/>
|
||
{recentEmails.length > 0 && (
|
||
<datalist id="tp-email-suggestions">
|
||
{recentEmails.map((e) => (
|
||
<option key={e} value={e} />
|
||
))}
|
||
</datalist>
|
||
)}
|
||
<label className="mt-2.5 flex items-start gap-2 text-[13px] leading-snug text-[var(--ink-muted)]">
|
||
<input
|
||
type="checkbox"
|
||
checked={notify}
|
||
onChange={(e) => setNotify(e.target.checked)}
|
||
className="mt-0.5 accent-[var(--share)]"
|
||
/>
|
||
{t.notify}
|
||
</label>
|
||
{error && (
|
||
<p className="mt-2 text-[13px] font-bold text-[#ff6b6b]">{error}</p>
|
||
)}
|
||
<button
|
||
onClick={confirmSubmit}
|
||
disabled={!emailValid || submitting}
|
||
className="btn-share mt-3.5 flex w-full items-center justify-center gap-1.5 rounded-2xl py-3 text-[15px] font-extrabold transition active:scale-[0.99] disabled:opacity-[0.72]"
|
||
>
|
||
{submitting ? "…" : <>{t.submit} <span aria-hidden>→</span></>}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */}
|
||
{!finished && step === "done" && outcome && (
|
||
<div className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
|
||
<div className="text-[13px] text-[var(--ink-muted)]">{t.myPick}</div>
|
||
<div className="mt-1 text-[26px] font-extrabold">
|
||
{leftShort} {flip ? scoreB : scoreA}-{flip ? scoreA : scoreB} {rightShort}
|
||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||
{outcomeLabel(match, outcome, lang)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
|
||
{matched.length > 0
|
||
? t.sameAI(
|
||
matched.map((m) => m.model).join("·"),
|
||
matched.some((m) => m.exact),
|
||
)
|
||
: t.soloPick}
|
||
</p>
|
||
<div className="mt-4 grid grid-cols-2 gap-2.5">
|
||
<button onClick={nativeShare} className="btn-mint rounded-xl py-3 text-[15px] font-bold active:scale-[0.99]">
|
||
{t.shareThis}
|
||
</button>
|
||
<button onClick={copyLink} className="rounded-xl border border-[var(--line-d)] py-3 text-[15px] font-bold active:scale-[0.99]">
|
||
{copied ? t.copied : t.copyLink}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
|
||
{myPreds.length > 0 && (
|
||
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||
<div className="mb-3 flex items-baseline justify-between gap-2">
|
||
<h3 className="text-[16px] font-extrabold">{t.myVotesTitle}</h3>
|
||
<span className="shrink-0 text-[12px] text-[var(--ink-muted)]">
|
||
{t.myVotesSub}
|
||
</span>
|
||
</div>
|
||
<ul className="flex flex-col gap-2">
|
||
{(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => {
|
||
const isThis = p.matchId === match.matchId;
|
||
const pFlip = p.teamB.code === "KOR" && p.teamA.code !== "KOR";
|
||
const pA = teamShort(p.teamA, lang);
|
||
const pB = teamShort(p.teamB, lang);
|
||
const pLeft = pFlip ? pB : pA;
|
||
const pRight = pFlip ? pA : pB;
|
||
const pickTxt =
|
||
p.outcome === "TEAM_A_WIN"
|
||
? `${pA} ${t.win}`
|
||
: p.outcome === "TEAM_B_WIN"
|
||
? `${pB} ${t.win}`
|
||
: t.drawLabel;
|
||
return (
|
||
<li
|
||
key={p.matchId}
|
||
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
|
||
isThis
|
||
? "border-[var(--share)] bg-[rgba(166,94,255,0.1)]"
|
||
: "border-[var(--line-d)]"
|
||
}`}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="text-[15px] font-bold text-white">
|
||
{pLeft} {pFlip ? p.scoreB : p.scoreA}-{pFlip ? p.scoreA : p.scoreB} {pRight}
|
||
</div>
|
||
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">
|
||
{pickTxt}
|
||
{isThis && (
|
||
<span className="text-[var(--share)]"> · {t.thisMatch}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{p.result ? (
|
||
<span
|
||
className={`shrink-0 text-[13px] font-bold ${
|
||
p.result.hitOutcome
|
||
? "text-[var(--share)]"
|
||
: "text-[var(--ink-muted)]"
|
||
}`}
|
||
>
|
||
{p.result.hitOutcome ? t.hit : t.miss}
|
||
</span>
|
||
) : (
|
||
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">
|
||
{fmtSubmitted(p.submittedAt)}
|
||
</span>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{myPreds.length > 1 && (
|
||
<button
|
||
onClick={() => setVotesExpanded((v) => !v)}
|
||
className="mt-3 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
|
||
>
|
||
{votesExpanded
|
||
? t.myVotesShowLess
|
||
: t.myVotesShowMore(myPreds.length - 1)}
|
||
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
|
||
</button>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
|
||
<RankingBoard lang={lang} />
|
||
|
||
{/* ===== 골드 상금 (003) ===== */}
|
||
<section className="gold-card mt-5 rounded-2xl p-5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-1.5 text-[18px] font-extrabold text-[var(--gold-border)]">
|
||
<span>₩1,000,000 Final Challenge</span>
|
||
<span>★</span>
|
||
</div>
|
||
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
|
||
{t.goldDesc}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowRules(true)}
|
||
className="gold-btn shrink-0 whitespace-pre-line rounded-xl px-4 py-3.5 text-[14px] font-extrabold leading-tight active:scale-[0.98]"
|
||
>
|
||
{t.goldBtn}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ===== 룰 설명 모달 ===== */}
|
||
{showRules && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4"
|
||
onClick={() => setShowRules(false)}
|
||
>
|
||
<div
|
||
className="w-full max-w-[420px] rounded-2xl border border-[var(--gold-border)]/45 bg-[var(--bg2)] p-5"
|
||
style={{ boxShadow: "0 20px 60px rgba(0,0,0,0.6)" }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center gap-1.5 text-[18px] font-extrabold text-[var(--gold-border)]">
|
||
<span>{t.rulesTitle}</span>
|
||
<span>★</span>
|
||
</div>
|
||
<p className="mt-2 text-[13px] leading-snug text-white/80">{t.rulesCumulative}</p>
|
||
|
||
<div className="mt-4 space-y-1.5">
|
||
{(
|
||
[
|
||
[t.rulesExact, 5],
|
||
[t.rulesCloseGrade, 3],
|
||
[t.rulesOutcome, 2],
|
||
[t.rulesPartial, 1],
|
||
[t.rulesMiss, 0],
|
||
] as [string, number][]
|
||
).map(([label, pt]) => (
|
||
<div
|
||
key={label}
|
||
className="flex items-center justify-between rounded-lg border border-[var(--line-d)] px-3 py-2 text-[13px]"
|
||
>
|
||
<span className="font-semibold text-white/85">{label}</span>
|
||
<span className="font-extrabold text-[var(--green)]">
|
||
{pt}
|
||
{lang === "en" ? " pt" : "점"}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<p className="mt-4 rounded-lg border border-[var(--mint)]/35 bg-[var(--mint)]/10 px-3 py-2.5 text-[12.5px] font-bold leading-snug text-[var(--mint)]">
|
||
{t.rulesEmailRequired}
|
||
</p>
|
||
|
||
<button
|
||
onClick={() => setShowRules(false)}
|
||
className="btn-mint mt-4 w-full rounded-xl py-3 text-[15px] font-extrabold active:scale-[0.99]"
|
||
>
|
||
{t.rulesCloseBtn}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
|
||
<section className="mt-6">
|
||
<div className="mb-2.5 text-[20px] font-extrabold">
|
||
Crowd Pick{" "}
|
||
<span className="text-[14px] text-[var(--ink-muted)]">{t.crowdSub}</span>
|
||
</div>
|
||
{/* 비율은 분포 합 기준 + 합이 항상 100% 되도록 보정(최대 잔여 방식) */}
|
||
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
|
||
{(() => {
|
||
const [a, d, b] = pctParts([crowd.teamAWin, crowd.draw, crowd.teamBWin]);
|
||
return flip ? (
|
||
<>
|
||
<CrowdSeg team={match.teamB} value={b} tone="a" />
|
||
<CrowdSeg value={d} tone="draw" />
|
||
<CrowdSeg team={match.teamA} value={a} tone="b" />
|
||
</>
|
||
) : (
|
||
<>
|
||
<CrowdSeg team={match.teamA} value={a} tone="a" />
|
||
<CrowdSeg value={d} tone="draw" />
|
||
<CrowdSeg team={match.teamB} value={b} tone="b" />
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
|
||
{t.joined(crowd.total.toLocaleString())}
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Stepper({
|
||
label,
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
onChange: (n: number) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
<StepBtn disabled={disabled || value <= 0} onClick={() => onChange(Math.max(0, value - 1))}>
|
||
−
|
||
</StepBtn>
|
||
<div className="flex w-12 flex-col items-center">
|
||
<span className="font-mono text-[32px] font-extrabold leading-none tabular-nums">
|
||
{value}
|
||
</span>
|
||
<span className="mt-1 text-[11px] text-[var(--ink-muted)]">{label}</span>
|
||
</div>
|
||
<StepBtn disabled={disabled || value >= 9} onClick={() => onChange(Math.min(9, value + 1))}>
|
||
+
|
||
</StepBtn>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StepBtn({
|
||
children,
|
||
onClick,
|
||
disabled,
|
||
}: {
|
||
children: React.ReactNode;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className="grid h-11 w-11 place-items-center rounded-xl border border-[var(--line-l)] bg-white text-[22px] font-bold leading-none text-[var(--ink)] active:scale-95 disabled:opacity-30"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CrowdSeg({
|
||
team,
|
||
value,
|
||
tone,
|
||
}: {
|
||
team?: Team;
|
||
value: number;
|
||
tone: "a" | "draw" | "b";
|
||
}) {
|
||
const bg = tone === "draw" ? "#2b313c" : tone === "a" ? "rgba(74,255,160,0.22)" : "#222831";
|
||
return (
|
||
<div
|
||
className="flex items-center justify-center gap-1.5 border-r border-[var(--line-d)] text-[16px] font-extrabold last:border-r-0"
|
||
style={{ width: `${value}%`, background: bg, minWidth: 56 }}
|
||
>
|
||
{team && <TeamFlag team={team} className="h-4 w-6" />}
|
||
<span>{value}%</span>
|
||
</div>
|
||
);
|
||
}
|