o2o-triple-pick/frontend/src/components/Arena.tsx

664 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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}&nbsp;-&nbsp;{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>
);
}