o2o-triple-pick/components/Arena.tsx

567 lines
21 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.

"use client";
import { useEffect, useMemo, useState } from "react";
import { DEMO_FORCE_OPEN, MODEL_VERSIONS } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
import { type Lang, dict, teamShort } from "@/lib/i18n";
import type {
Outcome,
CrowdStats,
ModelName,
Match,
AIPrediction,
Team,
} from "@/lib/types";
import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag";
import {
fetchMyPredictions,
saveMyPrediction,
rememberEmail,
recallEmail,
type MyPrediction,
} from "@/lib/myPredictions";
type Step = "form" | "done";
const MODEL_ICON: Record<ModelName, string> = {
GPT: "/icons/gpt.png",
Claude: "/icons/claude.jpg",
Gemini: "/icons/gemini.jpeg",
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
function fmtSubmitted(iso: string): string {
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
return m ? `${+m[1]}.${+m[2]}` : "";
}
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);
const genDate = (predictions[0]?.generatedAt ?? "").replace(/-/g, ".");
const modelVersions = predictions.map((p) => MODEL_VERSIONS[p.model]).join(" · ");
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [scoreA, setScoreA] = useState(2);
const [scoreB, setScoreB] = useState(1);
const [step, setStep] = useState<Step>("form");
const [email, setEmail] = useState("");
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
const [votesExpanded, setVotesExpanded] = useState(false);
// 재방문 시: 기억된 이메일 복원 → 내 지난 예측 로드 (로그인 대체)
useEffect(() => {
const saved = recallEmail();
if (!saved) return;
setEmail(saved);
fetchMyPredictions(saved).then(setMyPreds);
}, []);
// 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 강조)
const myThisMatch = useMemo(
() => myPreds.find((p) => p.matchId === match.matchId),
[myPreds, match.matchId],
);
useEffect(() => {
if (myThisMatch) {
setScoreA(myThisMatch.scoreA);
setScoreB(myThisMatch.scoreB);
}
}, [myThisMatch]);
// 이메일 입력이 유효해지면 그 즉시 내 기록 조회(다른 기기/세션 흔적 표시)
const onEmailChange = (v: string) => {
setEmail(v);
if (EMAIL_RE.test(v.trim())) fetchMyPredictions(v).then(setMyPreds);
};
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
useEffect(() => {
setOutcome(
scoreA > scoreB ? "TEAM_A_WIN" : scoreA < scoreB ? "TEAM_B_WIN" : "DRAW",
);
}, [scoreA, scoreB]);
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
const now = Date.now();
const finished = !!match.result;
const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime();
const locked = now >= new Date(match.lockAt).getTime();
const disabled = finished || locked || notOpen;
const ctaLabel = finished
? t.ctaResult
: notOpen
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
: locked
? t.ctaLocked
: 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 (!emailValid || !outcome) return;
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
if (!myThisMatch) {
setCrowd((c) => ({
...c,
total: c.total + 1,
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
}));
}
// 내 예측 저장(경기당 1건 upsert) + 이메일 기억 → 재방문 자동 복원
await saveMyPrediction(email, {
matchId: match.matchId,
teamAShort: aShort,
teamBShort: bShort,
kickoffKst: match.kickoffKst,
outcome,
scoreA,
scoreB,
submittedAt: new Date().toISOString(),
});
rememberEmail(email);
setMyPreds(await fetchMyPredictions(email));
setStep("done");
};
const shareText = useMemo(() => {
if (!outcome) return "";
const me = `${aShort} ${scoreA}-${scoreB} ${bShort}`;
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 && !notOpen && (
<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 flex items-end justify-between gap-2">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.aiBattle}</h2>
<div className="flex-1 pb-1 text-center text-[11px] leading-snug">
<div className="font-bold text-[var(--ink)]">
{t.genLabel} · {genDate} 00:00 KST
</div>
<div className="text-[var(--ink-muted)]">{modelVersions}</div>
</div>
</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">
{p.scoreA}&nbsp;-&nbsp;{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">
{(
[
["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">
<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">
{aShort} {match.result.scoreA}-{match.result.scoreB} {bShort}
<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 hit = p.outcome === match.result!.outcome;
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={hit ? "font-bold text-[var(--green)]" : "text-white/45"}>
{hit ? t.hit : t.miss}
</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"
value={email}
onChange={(e) => onEmailChange(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)]"
/>
<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>
<button
onClick={confirmSubmit}
disabled={!emailValid}
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]"
>
{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">
{aShort} {scoreA}-{scoreB} {bShort}
<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 pickTxt =
p.outcome === "TEAM_A_WIN"
? `${p.teamAShort} ${t.win}`
: p.outcome === "TEAM_B_WIN"
? `${p.teamBShort} ${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">
{p.teamAShort} {p.scoreA}-{p.scoreB} {p.teamBShort}
</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.showLess : t.showMore(myPreds.length - 1)}
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
</button>
)}
</section>
)}
{/* ===== 골드 상금 (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 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>
{/* ===== 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>
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
<CrowdSeg team={match.teamA} value={pct(crowd.teamAWin, crowd.total)} tone="a" />
<CrowdSeg value={pct(crowd.draw, crowd.total)} tone="draw" />
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, crowd.total)} 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>
);
}