434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { DEMO_FORCE_OPEN } 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";
|
||
|
||
type Step = "pick" | "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@]+$/;
|
||
|
||
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 [outcome, setOutcome] = useState<Outcome | null>(null);
|
||
const [scoreA, setScoreA] = useState(2);
|
||
const [scoreB, setScoreB] = useState(1);
|
||
const [step, setStep] = useState<Step>("pick");
|
||
const [email, setEmail] = useState("");
|
||
const [notify, setNotify] = useState(true);
|
||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||
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 submitPick = () => {
|
||
if (!outcome) return;
|
||
setStep("form");
|
||
};
|
||
const confirmSubmit = () => {
|
||
if (!EMAIL_RE.test(email.trim())) return;
|
||
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),
|
||
}));
|
||
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">
|
||
<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">
|
||
{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">
|
||
{(
|
||
[
|
||
["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>
|
||
)}
|
||
|
||
{/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */}
|
||
{!finished && step === "pick" && (
|
||
<button
|
||
onClick={submitPick}
|
||
disabled={!outcome || disabled}
|
||
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
|
||
>
|
||
{ctaLabel}
|
||
</button>
|
||
)}
|
||
|
||
{/* ===== 폼: 이메일만 (D5, 닉네임 제거) ===== */}
|
||
{!finished && step === "form" && (
|
||
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||
<input
|
||
type="email"
|
||
inputMode="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder={t.emailPh}
|
||
className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-3 text-[15px] text-white outline-none focus:border-[var(--green)]"
|
||
/>
|
||
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
|
||
<input
|
||
type="checkbox"
|
||
checked={notify}
|
||
onChange={(e) => setNotify(e.target.checked)}
|
||
className="mt-0.5 accent-[var(--mint)]"
|
||
/>
|
||
{t.notify}
|
||
</label>
|
||
<button
|
||
onClick={confirmSubmit}
|
||
disabled={!EMAIL_RE.test(email.trim())}
|
||
className="btn-mint w-full rounded-xl py-3.5 text-[17px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
|
||
>
|
||
{t.submit}
|
||
</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>
|
||
)}
|
||
|
||
{/* ===== 골드 상금 (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>
|
||
);
|
||
}
|