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 = { 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("DRAW"); const [scoreA, setScoreA] = useState(0); const [scoreB, setScoreB] = useState(0); const [step, setStep] = useState("form"); const [recentEmails, setRecentEmails] = useState(getRecentEmails); const [email, setEmail] = useState(""); // 칸은 비우고, 입력/포커스 시 datalist 드롭다운으로만 자동완성 const [notify, setNotify] = useState(true); const [crowd, setCrowd] = useState(initialCrowd); const [copied, setCopied] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [showRules, setShowRules] = useState(false); const [myPreds, setMyPreds] = useState([]); 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 && (
)} {/* ===== 레이어드 화이트 시트 (002) ===== */}

{t.aiBattle}

{predictions.map((p) => { const wp = winProb(match, p); const wpLabel = wp.team ? t.winProb(teamShort(wp.team, lang)) : t.drawOdds; return (
{p.model}
{p.model}
{/* D3: 승리 예측 팀의 승리 확률 + 승리팀 국기 */}
{wp.team && ( )} {wpLabel} {wp.pct}%
{flip ? p.scoreB : p.scoreA} - {flip ? p.scoreA : p.scoreB}
{/* 메인 리즌 강조 (D3: 확률은 텍스트, 바 제거) */}

“{p.reasonShort}”

); })}
{/* 당신의 선택 */}
{t.yourPickLabel} {t.yourChoice}
{( (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]) => ( ))}
{flip ? ( <> : ) : ( <> : )}
{/* ===== 종료된 경기: 결과 보기 ===== */} {finished && match.result && (
{t.finalResult}
{leftShort} {flip ? match.result.scoreB : match.result.scoreA}-{flip ? match.result.scoreA : match.result.scoreB} {rightShort} {outcomeLabel(match, match.result.outcome, lang)}
{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 (
{p.model} {label}
); })}
)} {/* ===== 투표 마감/오픈 전: 상태 버튼만 ===== */} {!finished && disabled && ( )} {/* ===== 투표 제출 (D5): 이메일 + 단일 CTA를 한 카드로 통합 ===== */} {!finished && !disabled && step === "form" && (
{t.emailGate}
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 && ( {recentEmails.map((e) => ( )} {error && (

{error}

)}
)} {/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */} {!finished && step === "done" && outcome && (
{t.myPick}
{leftShort} {flip ? scoreB : scoreA}-{flip ? scoreA : scoreB} {rightShort} {outcomeLabel(match, outcome, lang)}

{matched.length > 0 ? t.sameAI( matched.map((m) => m.model).join("·"), matched.some((m) => m.exact), ) : t.soloPick}

)} {/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */} {myPreds.length > 0 && (

{t.myVotesTitle}

{t.myVotesSub}
    {(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 (
  • {pLeft} {pFlip ? p.scoreB : p.scoreA}-{pFlip ? p.scoreA : p.scoreB} {pRight}
    {pickTxt} {isThis && ( · {t.thisMatch} )}
    {p.result ? ( {p.result.hitOutcome ? t.hit : t.miss} ) : ( {fmtSubmitted(p.submittedAt)} )}
  • ); })}
{myPreds.length > 1 && ( )}
)} {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */} {/* ===== 골드 상금 (003) ===== */}
₩1,000,000 Final Challenge

{t.goldDesc}

{/* ===== 룰 설명 모달 ===== */} {showRules && (
setShowRules(false)} >
e.stopPropagation()} >
{t.rulesTitle}

{t.rulesCumulative}

{( [ [t.rulesExact, 5], [t.rulesCloseGrade, 3], [t.rulesOutcome, 2], [t.rulesPartial, 1], [t.rulesMiss, 0], ] as [string, number][] ).map(([label, pt]) => (
{label} {pt} {lang === "en" ? " pt" : "점"}
))}

{t.rulesEmailRequired}

)} {/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
Crowd Pick{" "} {t.crowdSub}
{/* 비율은 분포 합 기준 + 합이 항상 100% 되도록 보정(최대 잔여 방식) */}
{(() => { const [a, d, b] = pctParts([crowd.teamAWin, crowd.draw, crowd.teamBWin]); return flip ? ( <> ) : ( <> ); })()}
{t.joined(crowd.total.toLocaleString())}
); } function Stepper({ label, value, onChange, disabled, }: { label: string; value: number; onChange: (n: number) => void; disabled?: boolean; }) { return (
onChange(Math.max(0, value - 1))}> −
{value} {label}
= 9} onClick={() => onChange(Math.min(9, value + 1))}> +
); } function StepBtn({ children, onClick, disabled, }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; }) { return ( ); } 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 (
{team && } {value}%
); }