371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL } from "@/lib/mockData";
|
||
import { outcomeLabel, pct } from "@/lib/format";
|
||
import type { Outcome, CrowdStats, ModelName } from "@/lib/types";
|
||
|
||
type Step = "pick" | "form" | "done";
|
||
|
||
const MODEL_COLOR: Record<ModelName, string> = {
|
||
GPT: "var(--gpt)",
|
||
Claude: "var(--claude)",
|
||
Gemini: "var(--gemini)",
|
||
};
|
||
const MODEL_ICON: Record<ModelName, string> = {
|
||
GPT: "/icons/gpt.png",
|
||
Claude: "/icons/claude.jpg",
|
||
Gemini: "/icons/gemini.jpeg",
|
||
};
|
||
|
||
export default function Arena() {
|
||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||
const [scoreA, setScoreA] = useState(2);
|
||
const [scoreB, setScoreB] = useState(1);
|
||
const [step, setStep] = useState<Step>("pick");
|
||
const [nickname, setNickname] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [notify, setNotify] = useState(true);
|
||
const [crowd, setCrowd] = useState<CrowdStats>(CROWD);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const locked = Date.now() >= new Date(MATCH.lockAt).getTime();
|
||
|
||
const matched = useMemo(() => {
|
||
if (!outcome) return [];
|
||
return AI_PREDICTIONS.filter((p) => p.outcome === outcome).map((p) => ({
|
||
model: p.model,
|
||
exact: p.scoreA === scoreA && p.scoreB === scoreB,
|
||
}));
|
||
}, [outcome, scoreA, scoreB]);
|
||
|
||
const submitPick = () => {
|
||
if (!outcome) return;
|
||
setStep("form");
|
||
};
|
||
const confirmSubmit = () => {
|
||
if (nickname.trim().length < 2) 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 = `${MATCH.teamA.shortName} ${scoreA}-${scoreB} ${MATCH.teamB.shortName}`;
|
||
const sameAI = matched.map((m) => m.model).join("·");
|
||
return sameAI
|
||
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
|
||
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
||
}, [outcome, scoreA, scoreB, matched]);
|
||
|
||
const copyLink = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(`${shareText}\n${LANDING_URL}`);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1800);
|
||
} catch {
|
||
setCopied(false);
|
||
}
|
||
};
|
||
const nativeShare = async () => {
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({ text: shareText, url: LANDING_URL });
|
||
} catch {
|
||
/* cancelled */
|
||
}
|
||
} else copyLink();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
||
<section className="sheet mt-6 p-5 text-[var(--ink)]">
|
||
<div className="mb-3 flex items-end gap-2">
|
||
<h2 className="text-[22px] font-extrabold">AI의 예측</h2>
|
||
<span className="mb-1.5 h-[4px] w-10 rounded bg-[var(--green)]" />
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{AI_PREDICTIONS.map((p) => (
|
||
<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>
|
||
<div className="mt-1 text-[11px] font-semibold text-[var(--ink-muted)]">
|
||
Confidence
|
||
</div>
|
||
</div>
|
||
<div className="ml-auto flex shrink-0 items-center gap-3">
|
||
<div className="whitespace-nowrap font-mono text-[28px] font-extrabold tabular-nums">
|
||
{p.scoreA} - {p.scoreB}
|
||
</div>
|
||
<div className="w-11 text-right text-[18px] font-extrabold">
|
||
{p.confidencePct}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2.5 h-2.5 w-full overflow-hidden rounded-full bg-[#eceee9]">
|
||
<div
|
||
className="bar-anim h-full rounded-full"
|
||
style={{
|
||
width: `${p.confidencePct}%`,
|
||
background: MODEL_COLOR[p.model],
|
||
}}
|
||
/>
|
||
</div>
|
||
<p className="mt-2 text-[13px] leading-snug text-[var(--ink-muted)]">
|
||
{p.reasonShort}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 당신의 선택 */}
|
||
<div className="mt-5 flex items-center justify-between">
|
||
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
|
||
Your Pick · 내 예측
|
||
</span>
|
||
<span className="text-[20px] font-extrabold">당신의 선택</span>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
{(
|
||
[
|
||
["TEAM_A_WIN", `${MATCH.teamA.shortName} 승`],
|
||
["DRAW", "무승부"],
|
||
["TEAM_B_WIN", `${MATCH.teamB.shortName} 승`],
|
||
] as [Outcome, string][]
|
||
).map(([val, label]) => (
|
||
<button
|
||
key={val}
|
||
disabled={locked}
|
||
onClick={() => setOutcome(val)}
|
||
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition ${
|
||
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={MATCH.teamA.shortName} value={scoreA} onChange={setScoreA} disabled={locked} />
|
||
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
|
||
<Stepper label={MATCH.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={locked} />
|
||
</div>
|
||
</section>
|
||
|
||
{/* ===== CTA 민트 #85FCA8 ===== */}
|
||
{step === "pick" && (
|
||
<button
|
||
onClick={submitPick}
|
||
disabled={!outcome || locked}
|
||
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"
|
||
>
|
||
{locked ? "예측 마감" : "AI보다 잘 맞히기"}
|
||
</button>
|
||
)}
|
||
|
||
{step === "form" && (
|
||
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||
<input
|
||
value={nickname}
|
||
onChange={(e) => setNickname(e.target.value)}
|
||
maxLength={16}
|
||
placeholder="닉네임 (2~16자)"
|
||
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)]"
|
||
/>
|
||
<input
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder="이메일 (선택 · 결과 알림)"
|
||
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-center gap-2 text-[13px] text-[var(--ink-muted)]">
|
||
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} className="accent-[var(--mint)]" />
|
||
경기 결과 나오면 알려주세요 (AI 이겼는지 확인)
|
||
</label>
|
||
<button
|
||
onClick={confirmSubmit}
|
||
disabled={nickname.trim().length < 2}
|
||
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"
|
||
>
|
||
제출하고 AI와 겨루기
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{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)]">내 예측</div>
|
||
<div className="mt-1 text-[26px] font-extrabold">
|
||
{MATCH.teamA.shortName} {scoreA}-{scoreB} {MATCH.teamB.shortName}
|
||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||
{outcomeLabel(MATCH, outcome)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
|
||
{matched.length > 0 ? (
|
||
<>
|
||
당신은 <b>{matched.map((m) => m.model).join("·")}</b>와 같은
|
||
선택입니다.{matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "}
|
||
나머지 AI는 생각이 다릅니다.
|
||
</>
|
||
) : (
|
||
<>당신은 AI 셋 모두와 다른 선택! 소신 픽 🔥</>
|
||
)}
|
||
</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]">
|
||
내 픽 자랑하기
|
||
</button>
|
||
<button onClick={copyLink} className="rounded-xl border border-[var(--line-d)] py-3 text-[15px] font-bold active:scale-[0.99]">
|
||
{copied ? "복사됨 ✓" : "링크 복사"}
|
||
</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">
|
||
<b className="text-[var(--gold-border)]">NEW</b> 정확한 스코어까지
|
||
맞히면 100만원 상금에 도전하세요
|
||
</p>
|
||
</div>
|
||
<button className="gold-btn shrink-0 rounded-xl px-4 py-3.5 text-[14px] font-extrabold leading-tight active:scale-[0.98]">
|
||
100만 원에
|
||
<br />
|
||
도전하기
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ===== Crowd Pick (003) ===== */}
|
||
<section className="mt-6">
|
||
<div className="mb-2.5 text-[20px] font-extrabold">
|
||
Crowd Pick{" "}
|
||
<span className="text-[14px] text-[var(--ink-muted)]">
|
||
| 참여자들의 선택
|
||
</span>
|
||
</div>
|
||
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
|
||
<CrowdSeg flag="/icons/kor.png" value={pct(crowd.teamAWin, crowd.total)} tone="kor" />
|
||
<CrowdSeg value={pct(crowd.draw, crowd.total)} tone="draw" />
|
||
<CrowdSeg cze value={pct(crowd.teamBWin, crowd.total)} tone="cze" />
|
||
</div>
|
||
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
|
||
{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({
|
||
flag,
|
||
cze,
|
||
value,
|
||
tone,
|
||
}: {
|
||
flag?: string;
|
||
cze?: boolean;
|
||
value: number;
|
||
tone: "kor" | "draw" | "cze";
|
||
}) {
|
||
const bg =
|
||
tone === "draw" ? "#2b313c" : tone === "kor" ? "rgba(62,224,138,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 }}
|
||
>
|
||
{flag && <img src={flag} alt="KOR" className="h-4 w-6 rounded object-cover" />}
|
||
{cze && (
|
||
<svg viewBox="0 0 6 4" className="h-4 w-6 rounded" preserveAspectRatio="none">
|
||
<rect width="6" height="2" y="0" fill="#fff" />
|
||
<rect width="6" height="2" y="2" fill="#d7141a" />
|
||
<polygon points="0,0 3,2 0,4" fill="#11457e" />
|
||
</svg>
|
||
)}
|
||
<span>{value}%</span>
|
||
</div>
|
||
);
|
||
}
|