o2o-triple-pick/components/Arena.tsx

380 lines
14 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 { useMemo, useState } from "react";
import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL, DEMO_FORCE_OPEN } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay } 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);
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각)
const now = Date.now();
const notOpen = !DEMO_FORCE_OPEN && now < new Date(MATCH.opensAt).getTime();
const locked = now >= new Date(MATCH.lockAt).getTime();
const disabled = locked || notOpen; // 폼 비활성
const ctaLabel = notOpen
? `투표 오픈 ${kickoffDisplay(MATCH.opensAt)}`
: locked
? "예측 마감 (경기 시작)"
: "AI보다 잘 맞히기";
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}&nbsp;-&nbsp;{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={disabled}
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={disabled} />
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
<Stepper label={MATCH.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={disabled} />
</div>
</section>
{/* ===== CTA 민트 #85FCA8 ===== */}
{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>
)}
{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>
);
}