i18n (KO/EN), square flags, score→outcome auto, copy & legal fixes

- E. 영문 페이지: lib/i18n.ts(KO/EN 사전) + ?lang 스위치(LangSwitch, AI Prediction Arena 우측, 화이트 활성) + 전 컴포넌트 i18n. World Cup 2026(영문 연도 후행), 영문 AI 근거/날짜/카운트다운 단위
- A. "글로벌 축구"→"2026 월드컵"(Hero 알약·meta), 영문은 World Cup 2026
- B. Crowd Pick 설명 문구 삭제
- C. 국기 찌그러짐 수정(이모지 cqh 비례, CZE 비율보존) + 라운드 제거→사각형
- D. 점수 선택 시 승/무/패 자동 선택(useEffect)
- F. 인트로 카피 "가장 정확하게 예측한 … 100만원 상금"
- MatchupHUD 상단 진행 바(roundLabel→Final) 삭제
- 푸터: "Not official" 모호 → FIFA·공식 월드컵 비제휴 명시(KO/EN)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Haewon Kam 2026-06-09 15:55:06 +09:00
parent c66812d954
commit a8de6c858d
16 changed files with 504 additions and 199 deletions

View File

@ -62,7 +62,6 @@ body {
.flag-img {
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--line-d);
}

View File

@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "TriplePick 2026 | AI 글로벌 축구 승부예측 — GPT vs Claude vs Gemini",
title: "TriplePick 2026 | AI 2026 월드컵 승부예측 — GPT vs Claude vs Gemini",
description:
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
applicationName: "TriplePick",

View File

@ -6,6 +6,7 @@ import Arena from "@/components/Arena";
import Footer from "@/components/Footer";
import { GROUP_A } from "@/lib/schedule";
import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData";
import { parseLang, dict, teamShort } from "@/lib/i18n";
// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성
export function generateStaticParams() {
@ -32,42 +33,56 @@ export async function generateMetadata({
export default async function MatchPage({
params,
searchParams,
}: {
params: Promise<{ matchId: string }>;
searchParams: Promise<{ lang?: string }>;
}) {
const { matchId } = await params;
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) notFound();
const predictions = getPredictions(match);
const predictions = getPredictions(match, lang);
const crowd = getCrowd(match);
const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang);
const hook = lang === "en" ? t.hook(aShort, bShort) : match.hookText;
return (
<main className="shell">
<Hero
back
lang={lang}
share={{
url: matchUrl(match.matchId),
title: `${match.teamA.shortName} vs ${match.teamB.shortName} — TriplePick`,
text: `${match.hookText} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
title: `${aShort} vs ${bShort} — TriplePick`,
text:
lang === "en"
? `${hook} — see all 3 AI picks and make yours!`
: `${hook} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
}}
/>
{/* 경기별 후킹 카피 (한 줄) */}
<h1 className="mt-6 flex items-center justify-center gap-2 whitespace-nowrap text-[19px] font-extrabold leading-snug">
<span className="text-[var(--green)]"></span>
{match.hookText}
{hook}
<span className="text-[var(--green)]"></span>
</h1>
<MatchupHUD match={match} />
<MatchupHUD match={match} lang={lang} />
<Arena
match={match}
predictions={predictions}
crowd={crowd}
shareUrl={matchUrl(match.matchId)}
lang={lang}
/>
<Footer />
<Footer lang={lang} />
</main>
);
}

View File

@ -1,30 +1,35 @@
import Hero from "@/components/Hero";
import ScheduleBoard from "@/components/ScheduleBoard";
import Footer from "@/components/Footer";
import { parseLang, dict } from "@/lib/i18n";
// L1. 전체 일정 대시보드 (랜딩 진입)
export default function Home() {
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ lang?: string }>;
}) {
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
return (
<main className="shell">
<Hero />
<Hero lang={lang} />
{/* 기간 안내 + CTA 영역 */}
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
<p className="text-[14px] font-bold leading-snug">
GPT · Claude · Gemini
<p className="whitespace-pre-line text-[14px] font-bold leading-snug">
{t.introLead}
</p>
<p className="mt-1 text-[14px] font-bold leading-snug text-[var(--green)]">
{t.prizeLine1}
<br />
.
<br />
<span className="text-[var(--green)]">
,
<br />
<span className="whitespace-nowrap">100 </span>
</span>
<span className="whitespace-nowrap">{t.prizeLine2}</span>
</p>
</div>
<ScheduleBoard />
<Footer />
<ScheduleBoard lang={lang} />
<Footer lang={lang} />
</main>
);
}

View File

@ -1,14 +1,16 @@
"use client";
import { useMemo, useState } from "react";
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";
@ -28,12 +30,17 @@ export default function Arena({
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);
@ -43,6 +50,13 @@ export default function Arena({
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;
@ -50,12 +64,12 @@ export default function Arena({
const locked = now >= new Date(match.lockAt).getTime();
const disabled = finished || locked || notOpen;
const ctaLabel = finished
? "결과 보기"
? t.ctaResult
: notOpen
? `투표 오픈 ${kickoffDisplay(match.opensAt)}`
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
: locked
? "예측 마감 (경기 시작)"
: "AI보다 잘 맞히기";
? t.ctaLocked
: t.ctaBeat;
const matched = useMemo(() => {
if (!outcome) return [];
@ -82,12 +96,17 @@ export default function Arena({
const shareText = useMemo(() => {
if (!outcome) return "";
const me = `${match.teamA.shortName} ${scoreA}-${scoreB} ${match.teamB.shortName}`;
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, match]);
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
const copyLink = async () => {
try {
@ -102,7 +121,7 @@ export default function Arena({
if (navigator.share) {
try {
await navigator.share({
title: `${match.teamA.shortName} vs ${match.teamB.shortName} — TriplePick`,
title: `${aShort} vs ${bShort} — TriplePick`,
text: shareText,
url: shareUrl,
});
@ -117,19 +136,22 @@ export default function Arena({
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
{!finished && !notOpen && (
<div className="mt-4">
<Countdown to={match.lockAt} />
<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">AI </h2>
<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}
@ -151,7 +173,7 @@ export default function Arena({
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
)}
<span className="text-[12px] font-bold text-[var(--ink-muted)]">
{wp.label} <span className="text-[var(--gpt)]">{wp.pct}%</span>
{wpLabel} <span className="text-[var(--gpt)]">{wp.pct}%</span>
</span>
</div>
</div>
@ -172,17 +194,17 @@ export default function Arena({
{/* 당신의 선택 */}
<div className="mt-5 flex items-center justify-between">
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
Your Pick ·
{t.yourPickLabel}
</span>
<span className="text-[20px] font-extrabold"> </span>
<span className="text-[20px] font-extrabold">{t.yourChoice}</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}`],
["TEAM_A_WIN", `${aShort} ${t.win}`],
["DRAW", t.drawLabel],
["TEAM_B_WIN", `${bShort} ${t.win}`],
] as [Outcome, string][]
).map(([val, label]) => (
<button
@ -201,21 +223,20 @@ export default function Arena({
</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} />
<Stepper label={aShort} 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} />
<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)]"> </div>
<div className="text-[13px] text-[var(--ink-muted)]">{t.finalResult}</div>
<div className="mt-1 text-[26px] font-extrabold">
{match.teamA.shortName} {match.result.scoreA}-{match.result.scoreB}{" "}
{match.teamB.shortName}
{aShort} {match.result.scoreA}-{match.result.scoreB} {bShort}
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
{outcomeLabel(match, match.result.outcome)}
{outcomeLabel(match, match.result.outcome, lang)}
</span>
</div>
<div className="mt-3 flex flex-col gap-1.5">
@ -225,7 +246,7 @@ export default function Arena({
<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 ? "적중 ✓" : "빗나감"}
{hit ? t.hit : t.miss}
</span>
</div>
);
@ -253,7 +274,7 @@ export default function Arena({
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일 (결과·다음 경기 알림)"
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)]">
@ -263,14 +284,14 @@ export default function Arena({
onChange={(e) => setNotify(e.target.checked)}
className="mt-0.5 accent-[var(--mint)]"
/>
. ·100 .
{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"
>
AI
{t.submit}
</button>
</div>
)}
@ -278,30 +299,27 @@ export default function Arena({
{/* ===== 제출 완료: 같은 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)]"> </div>
<div className="text-[13px] text-[var(--ink-muted)]">{t.myPick}</div>
<div className="mt-1 text-[26px] font-extrabold">
{match.teamA.shortName} {scoreA}-{scoreB} {match.teamB.shortName}
{aShort} {scoreA}-{scoreB} {bShort}
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
{outcomeLabel(match, outcome)}
{outcomeLabel(match, outcome, lang)}
</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 ! 🔥</>
)}
{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 ? "복사됨 ✓" : "링크 복사"}
{copied ? t.copied : t.copyLink}
</button>
</div>
</div>
@ -316,25 +334,20 @@ export default function Arena({
<span></span>
</div>
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
· , 1 100
{t.goldDesc}
</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 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-1 text-[20px] font-extrabold">
<div className="mb-2.5 text-[20px] font-extrabold">
Crowd Pick{" "}
<span className="text-[14px] text-[var(--ink-muted)]">| </span>
</div>
<div className="mb-2.5 text-[11px] text-[var(--ink-muted)]">
(AI )
<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" />
@ -342,7 +355,7 @@ export default function Arena({
<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)]">
{crowd.total.toLocaleString()}
{t.joined(crowd.total.toLocaleString())}
</div>
</section>
</>
@ -403,7 +416,7 @@ function CrowdSeg({
value,
tone,
}: {
team?: import("@/lib/types").Team;
team?: Team;
value: number;
tone: "a" | "draw" | "b";
}) {

View File

@ -1,16 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { type Lang, dict } from "@/lib/i18n";
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
export default function Countdown({
to,
label = "투표 종료까지",
}: {
to: string;
label?: string;
}) {
export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang }) {
const t = dict(lang);
const label = t.cdUntil;
const [left, setLeft] = useState<number | null>(null);
useEffect(() => {
@ -26,7 +23,7 @@ export default function Countdown({
return (
<div className="flex items-center justify-center gap-2 rounded-xl border border-[var(--line-d)] bg-[var(--bg2)] px-3 py-2.5">
<span className="text-[12px] font-semibold text-[var(--ink-muted)]">
{ended ? "투표가 마감되었습니다" : label}
{ended ? t.cdClosed : label}
</span>
{!ended && (
<span
@ -34,20 +31,21 @@ export default function Countdown({
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
suppressHydrationWarning
>
{left === null ? "--:--:--" : fmt(left)}
{left === null ? "--:--:--" : fmt(left, lang)}
</span>
)}
</div>
);
}
function fmt(ms: number): string {
function fmt(ms: number, lang: Lang): string {
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (d > 0) return `${d}${pad(h)}:${pad(m)}:${pad(sec)}`;
const dayUnit = lang === "en" ? "d" : "일";
if (d > 0) return `${d}${dayUnit} ${pad(h)}:${pad(m)}:${pad(sec)}`;
return `${pad(h)}:${pad(m)}:${pad(sec)}`;
}

View File

@ -1,23 +1,19 @@
export default function Footer() {
import { type Lang, dict } from "@/lib/i18n";
export default function Footer({ lang = "ko" }: { lang?: Lang }) {
const t = dict(lang);
return (
<footer className="mt-6 text-center">
<p className="text-[10.5px] font-semibold text-white/75">
Independent AI prediction event. Not official.
{t.footerNotOfficial}
</p>
<p className="mt-1 text-[10.5px] leading-relaxed text-white/60">
AI , ···
.
{t.footerDisc1}
</p>
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-white/55">
<p>
· ·
. AI .
</p>
<p>
100 . ·
. · .
</p>
<p>{t.footerDisc2}</p>
<p>{t.footerDisc3}</p>
</div>
<div className="mt-3 text-[10px] text-white/70">

View File

@ -1,5 +1,7 @@
import Link from "next/link";
import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch";
import { type Lang, dict } from "@/lib/i18n";
type Share = { url: string; title: string; text: string };
@ -7,31 +9,42 @@ type Share = { url: string; title: string; text: string };
export default function Hero({
back = false,
share,
lang = "ko",
}: {
back?: boolean;
share?: Share;
lang?: Lang;
}) {
const t = dict(lang);
const home = lang === "en" ? "/?lang=en" : "/";
return (
<header className="pt-5 text-center">
{back && (
<div className="mb-3 flex items-center justify-between">
<Link
href="/"
href={home}
className="flex items-center gap-1 rounded-full border border-white/12 bg-white/8 px-3 py-1.5 text-[12px] font-bold text-white/85 active:scale-95"
>
{t.back}
</Link>
{share && <ShareButton {...share} />}
{share && (
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
)}
</div>
)}
<div className="font-impact text-[44px] leading-none tracking-tight">
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
</div>
<div className="mt-2 text-[16px] font-extrabold text-[var(--green)]">
AI Prediction Arena
<div className="relative mt-2">
<div className="text-[16px] font-extrabold text-[var(--green)]">
AI Prediction Arena
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2">
<LangSwitch lang={lang} />
</div>
</div>
<div className="mt-2 inline-block rounded-full border border-white/12 bg-white/8 px-3 py-1 text-[13px] font-semibold text-white/85">
AI
{t.heroPill}
</div>
</header>
);

28
components/LangSwitch.tsx Normal file
View File

@ -0,0 +1,28 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { Lang } from "@/lib/i18n";
// KO / EN 토글. 현재 경로 유지 + ?lang 토글.
export default function LangSwitch({ lang }: { lang: Lang }) {
const pathname = usePathname() || "/";
const href = (l: Lang) => (l === "en" ? `${pathname}?lang=en` : pathname);
const langs: Lang[] = ["ko", "en"];
return (
<div className="flex items-center overflow-hidden rounded-full border border-white/15 text-[11px] font-extrabold">
{langs.map((l) => (
<Link
key={l}
href={href(l)}
scroll={false}
className={`px-2.5 py-1 transition ${
lang === l ? "bg-white text-[#14171C]" : "text-white/65"
}`}
>
{l.toUpperCase()}
</Link>
))}
</div>
);
}

View File

@ -1,31 +1,15 @@
import type { Match } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format";
import { type Lang, dict } from "@/lib/i18n";
import BallIcon from "./BallIcon";
import TeamFlag from "./TeamFlag";
export default function MatchupHUD({ match }: { match: Match }) {
const { group, roundLabel } = match;
export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
const t = dict(lang);
const { group } = match;
const finished = !!match.result;
return (
<section className="mt-6">
{/* 진행 바: roundLabel → Final */}
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold">
<span className="rounded-md border border-[var(--green)] px-2.5 py-1 text-[var(--green)]">
{roundLabel}
</span>
<div className="relative h-px flex-1 bg-gradient-to-r from-[var(--green)] to-[var(--line-d)]">
<span className="absolute -left-0.5 top-1/2 -translate-y-1/2 text-[var(--green)]">
</span>
<span className="absolute -right-0.5 top-1/2 -translate-y-1/2 text-[var(--line-d)]">
</span>
</div>
<span className="rounded-md border border-[var(--line-d)] px-2.5 py-1 text-[var(--ink-muted)]">
Final
</span>
</div>
{/* 대결 카드 (녹색 글로우 보더) */}
<div
className="rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5"
@ -40,7 +24,7 @@ export default function MatchupHUD({ match }: { match: Match }) {
{match.teamB.name}
</div>
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
{kickoffDisplay(match.kickoffKst)} · Group {group}
{kickoffDisplay(match.kickoffKst, lang)} · {t.group(group)}
</div>
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div>
@ -71,7 +55,7 @@ export default function MatchupHUD({ match }: { match: Match }) {
{finished && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
·
{t.matchEnded}
</div>
)}
</div>

View File

@ -2,26 +2,28 @@ import Link from "next/link";
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
import { getPredictions, getCrowd } from "@/lib/mockData";
import { dateHeading, timeOnly } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
import type { Match } from "@/lib/types";
import TeamFlag from "./TeamFlag";
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
const PHASE_META: Record<MatchPhase, { label: string; cls: string }> = {
open: { label: "투표 중", cls: "border-[#94FBE0] text-[#94FBE0]" },
scheduled: { label: "오픈 예정", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" },
locked: { label: "투표 마감", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" },
finished: { label: "종료", cls: "border-white/15 text-white/55" },
const PHASE_CLS: Record<MatchPhase, string> = {
open: "border-[#94FBE0] text-[#94FBE0]",
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
locked: "border-[var(--line-d)] text-[var(--ink-muted)]",
finished: "border-white/15 text-white/55",
};
export default function ScheduleBoard() {
export default function ScheduleBoard({ lang = "ko" }: { lang?: Lang }) {
const t = dict(lang);
const groups = matchesByDate();
return (
<section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5">
<h2 className="shrink-0 text-[22px] font-extrabold"> </h2>
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
<span className="whitespace-nowrap text-[12px] text-white/55">
{t.schedGuide}
</span>
</div>
@ -29,11 +31,11 @@ export default function ScheduleBoard() {
{groups.map((g) => (
<div key={g.date}>
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
{dateHeading(g.date)}
{dateHeading(g.date, lang)}
</div>
<div className="flex flex-col gap-2.5">
{g.matches.map((m) => (
<MatchCard key={m.matchId} match={m} />
<MatchCard key={m.matchId} match={m} lang={lang} />
))}
</div>
</div>
@ -43,10 +45,10 @@ export default function ScheduleBoard() {
);
}
function MatchCard({ match }: { match: Match }) {
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
const t = dict(lang);
const phase = matchPhase(match);
const meta = PHASE_META[phase];
const preds = getPredictions(match);
const preds = getPredictions(match, lang);
const crowd = getCrowd(match);
// AI 픽 갈림 요약
@ -57,42 +59,45 @@ function MatchCard({ match }: { match: Match }) {
else tally.b++;
}
const split: string[] = [];
if (tally.a) split.push(`${match.teamA.shortName} ${tally.a}`);
if (tally.d) split.push(`${tally.d}`);
if (tally.b) split.push(`${match.teamB.shortName} ${tally.b}`);
if (tally.a) split.push(`${teamShort(match.teamA, lang)} ${tally.a}`);
if (tally.d) split.push(`${t.draw} ${tally.d}`);
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
return (
<Link
href={`/match/${match.matchId}`}
href={href}
className="block rounded-2xl border-2 border-[#94FBE0]/45 bg-[#171b21] p-4 transition active:scale-[0.99] hover:border-[#94FBE0]"
>
<div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70">
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> · {match.roundLabel}
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
{tRound(match.roundLabel, lang)}
</span>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${meta.cls}`}>
{meta.label}
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]}
</span>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div className="flex items-center gap-2">
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
<span className="truncate text-[15px] font-extrabold">{match.teamA.shortName}</span>
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div>
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
<div className="flex items-center justify-end gap-2">
<span className="truncate text-right text-[15px] font-extrabold">{match.teamB.shortName}</span>
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
<span>
<span className="font-semibold text-white/55">AI </span> {split.join(" · ")}
<span className="font-semibold text-white/55">{t.aiPicks}</span> {split.join(" · ")}
</span>
<span className="flex items-center gap-2">
<span>{crowd.total.toLocaleString()} </span>
<span>{t.joined(crowd.total.toLocaleString())}</span>
<span className="text-[#94FBE0]"></span>
</span>
</div>

View File

@ -7,10 +7,14 @@ export default function ShareButton({
url,
title,
text,
label = "투표 공유하기",
copiedLabel = "링크 복사됨 ✓",
}: {
url: string;
title: string;
text: string;
label?: string;
copiedLabel?: string;
}) {
const [copied, setCopied] = useState(false);
@ -37,7 +41,7 @@ export default function ShareButton({
onClick={onShare}
className="flex items-center rounded-full border border-[var(--share)]/55 bg-[var(--share)]/12 px-3 py-1.5 text-[12px] font-bold text-[var(--share)] transition active:scale-95"
>
{copied ? "링크 복사됨 ✓" : "투표 공유하기"}
{copied ? copiedLabel : label}
</button>
);
}

View File

@ -1,7 +1,7 @@
import type { Team } from "@/lib/types";
// 모든 경기에 일반화된 국기 렌더.
// KOR = 실제 png 자산, CZE = 인라인 SVG(기존 디자인 유지), 그 외 = 이모지 박스.
// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
export default function TeamFlag({
team,
className = "",
@ -22,8 +22,8 @@ export default function TeamFlag({
return (
<svg
viewBox="0 0 6 4"
className={`rounded-lg border border-[var(--line-d)] ${className}`}
preserveAspectRatio="none"
className={`border border-[var(--line-d)] ${className}`}
preserveAspectRatio="xMidYMid meet"
aria-label={team.name}
>
<rect width="6" height="2" y="0" fill="#ffffff" />
@ -32,13 +32,14 @@ export default function TeamFlag({
</svg>
);
}
// 그 외 국가 — 이모지 국기 (자산 추가 전 fallback)
// 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
return (
<span
className={`grid place-items-center overflow-hidden rounded-lg border border-[var(--line-d)] bg-white/[0.06] ${className}`}
className={`grid place-items-center overflow-hidden border border-[var(--line-d)] bg-white/[0.06] ${className}`}
style={{ containerType: "size" }}
aria-label={team.name}
>
<span className="leading-none" style={{ fontSize: "1.7em" }}>
<span className="leading-none" style={{ fontSize: "92cqh" }}>
{team.flag}
</span>
</span>

View File

@ -1,31 +1,32 @@
import type { Outcome, Match, AIPrediction, Team } from "./types";
import { type Lang, teamShort, dayName, monthEn, dict } from "./i18n";
export function outcomeLabel(match: Match, o: Outcome): string {
if (o === "TEAM_A_WIN") return `${match.teamA.shortName}`;
if (o === "TEAM_B_WIN") return `${match.teamB.shortName}`;
return "무승부";
export function outcomeLabel(match: Match, o: Outcome, lang: Lang = "ko"): string {
const d = dict(lang);
if (o === "TEAM_A_WIN") return `${teamShort(match.teamA, lang)} ${d.win}`;
if (o === "TEAM_B_WIN") return `${teamShort(match.teamB, lang)} ${d.win}`;
return d.drawLabel;
}
// D3: 확률 = "이긴다고 예측한 팀의 승리 확률". 무승부 예측이면 "무승부 가능성".
// D3: 확률 = 이긴다고 예측한 팀의 승리 확률. 라벨은 호출부에서 dict로 구성.
export function winProb(
match: Match,
p: Pick<AIPrediction, "outcome" | "confidencePct">,
): { team: Team | null; label: string; pct: number } {
if (p.outcome === "TEAM_A_WIN")
return { team: match.teamA, label: `${match.teamA.shortName} 승리 확률`, pct: p.confidencePct };
if (p.outcome === "TEAM_B_WIN")
return { team: match.teamB, label: `${match.teamB.shortName} 승리 확률`, pct: p.confidencePct };
return { team: null, label: "무승부 가능성", pct: p.confidencePct };
): { team: Team | null; pct: number } {
if (p.outcome === "TEAM_A_WIN") return { team: match.teamA, pct: p.confidencePct };
if (p.outcome === "TEAM_B_WIN") return { team: match.teamB, pct: p.confidencePct };
return { team: null, pct: p.confidencePct };
}
// "6월 12일 (금)" — 대시보드 날짜 그룹 헤더
export function dateHeading(iso: string): string {
const days = ["일", "월", "화", "수", "목", "금", "토"];
// "6월 12일 (금)" / "Jun 12 (Fri)" — 대시보드 날짜 그룹 헤더
export function dateHeading(iso: string, lang: Lang = "ko"): string {
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
if (!dm) return iso;
const [, y, mo, d] = dm;
const dow = days[new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay()];
return `${+mo}${+d}일 (${dow})`;
const dow = dayName(lang, new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay());
return lang === "en"
? `${monthEn(+mo)} ${+d} (${dow})`
: `${+mo}${+d}일 (${dow})`;
}
// "11:00" — 경기 카드 시간
@ -40,14 +41,12 @@ export function dateKey(iso: string): string {
return m ? m[1] : iso;
}
export function kickoffDisplay(iso: string): string {
// iso는 KST(+09:00). 런타임 TZ에 무관하게 ISO 문자열을 직접 파싱한다.
const days = ["일", "월", "화", "수", "목", "금", "토"];
// "6.12(금) 11:00 KST" — 요일명만 로케일 적용
export function kickoffDisplay(iso: string, lang: Lang = "ko"): string {
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!dm) return iso;
const [, y, mo, d, hh, mm] = dm;
// 요일은 달력 날짜 기준(UTC Date로 계산하면 TZ 무관)
const dow = days[new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay()];
const dow = dayName(lang, new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay());
return `${+mo}.${+d}(${dow}) ${hh}:${mm} KST`;
}

203
lib/i18n.ts Normal file
View File

@ -0,0 +1,203 @@
// 경량 i18n — ?lang=en 쿼리로 한/영 전환. SSG 유지(서버에서 lang 주입).
import type { Team } from "./types";
export type Lang = "ko" | "en";
export function parseLang(v: unknown): Lang {
return v === "en" ? "en" : "ko";
}
// 팀 짧은 표기 (KO=한글, EN=영문 약식)
const TEAM_EN: Record<string, string> = {
KOR: "Korea",
CZE: "Czechia",
MEX: "Mexico",
RSA: "S. Africa",
};
export function teamShort(team: Team, lang: Lang): string {
return lang === "en" ? TEAM_EN[team.code] ?? team.name : team.shortName;
}
const DAYS: Record<Lang, string[]> = {
ko: ["일", "월", "화", "수", "목", "금", "토"],
en: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
};
const MONTHS_EN = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
export function dayName(lang: Lang, dow: number): string {
return DAYS[lang][dow];
}
export function monthEn(m: number): string {
return MONTHS_EN[m - 1];
}
// roundLabel 안의 한국어 토막 번역
export function roundLabel(label: string, lang: Lang): string {
if (lang === "ko") return label;
return label.replace("개막전", "Opener");
}
type Dict = {
heroPill: string;
back: string;
share: string;
shareCopied: string;
introLead: string;
prizeLine1: string;
prizeLine2: string;
schedTitle: string;
schedGuide: string;
phase: { open: string; scheduled: string; locked: string; finished: string };
aiPicks: string;
draw: string;
joined: (n: string) => string;
aiBattle: string;
yourPickLabel: string;
yourChoice: string;
win: string;
drawLabel: string;
winProb: (team: string) => string;
drawOdds: string;
ctaBeat: string;
ctaResult: string;
ctaLocked: string;
ctaOpens: (d: string) => string;
emailPh: string;
notify: string;
submit: string;
myPick: string;
sameAI: (models: string, exact: boolean) => string;
soloPick: string;
shareThis: string;
copyLink: string;
copied: string;
goldDesc: string;
goldBtn: string;
crowdSub: string;
finalResult: string;
hit: string;
miss: string;
matchEnded: string;
cdUntil: string;
cdClosed: string;
final: string;
group: (g: string) => string;
footerNotOfficial: string;
footerDisc1: string;
footerDisc2: string;
footerDisc3: string;
hook: (a: string, b: string) => string;
};
export const DICT: Record<Lang, Dict> = {
ko: {
heroPill: "AI와 함께하는 2026 월드컵 승부예측 챌린지",
back: "← 전체 일정",
share: "투표 공유하기",
shareCopied: "링크 복사됨 ✓",
introLead: "GPT · Claude · Gemini가 매 경기를\n서로 다르게 예측합니다.",
prizeLine1: "가장 많이 참여하고, 가장 정확하게 예측한",
prizeLine2: "최종 우승자에게 100만원 상금",
schedTitle: "전체 일정",
schedGuide: "투표하고 싶은 경기를 선택해주세요",
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "종료" },
aiPicks: "AI 픽",
draw: "무",
joined: (n) => `${n}명 참여`,
aiBattle: "AI의 예측 대결",
yourPickLabel: "Your Pick · 내 예측",
yourChoice: "당신의 선택",
win: "승",
drawLabel: "무승부",
winProb: (t) => `${t} 승리 확률`,
drawOdds: "무승부 가능성",
ctaBeat: "AI보다 잘 맞히기",
ctaResult: "결과 보기",
ctaLocked: "예측 마감 (경기 시작)",
ctaOpens: (d) => `투표 오픈 ${d}`,
emailPh: "이메일 (결과·다음 경기 알림)",
notify: "경기 결과가 나오면 알려주세요. 다음 경기·100만원 챌린지 소식도 받겠습니다.",
submit: "제출하고 AI와 겨루기",
myPick: "내 예측",
sameAI: (m, exact) =>
`당신은 ${m}와 같은 선택입니다.${exact ? " 스코어까지 일치!" : ""} 나머지 AI는 생각이 다릅니다.`,
soloPick: "당신은 AI 셋 모두와 다른 선택! 소신 픽 🔥",
shareThis: "이 경기 공유하기",
copyLink: "링크 복사",
copied: "복사됨 ✓",
goldDesc: "경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원",
goldBtn: "100만 원에\n도전하기",
crowdSub: "| 참여자들의 선택",
finalResult: "최종 결과",
hit: "적중 ✓",
miss: "빗나감",
matchEnded: "경기 종료 · 최종 결과",
cdUntil: "투표 종료까지",
cdClosed: "투표가 마감되었습니다",
final: "Final",
group: (g) => `Group ${g}`,
footerNotOfficial:
"Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.",
footerDisc1: "FIFA 및 공식 월드컵과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).",
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
},
en: {
heroPill: "AI-powered World Cup 2026 match predictions",
back: "← All matches",
share: "Share vote",
shareCopied: "Link copied ✓",
introLead: "GPT · Claude · Gemini predict\nevery match differently.",
prizeLine1: "The most active, most accurate",
prizeLine2: "overall winner takes ₩1,000,000",
schedTitle: "All Matches",
schedGuide: "Pick a match to predict",
phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", finished: "Ended" },
aiPicks: "AI picks",
draw: "Draw",
joined: (n) => `${n} joined`,
aiBattle: "AI Prediction Battle",
yourPickLabel: "Your Pick",
yourChoice: "Your Choice",
win: "Win",
drawLabel: "Draw",
winProb: (t) => `${t} win prob.`,
drawOdds: "Draw odds",
ctaBeat: "Beat the AI",
ctaResult: "View result",
ctaLocked: "Closed (kickoff)",
ctaOpens: (d) => `Opens ${d}`,
emailPh: "Email (result & next-match alerts)",
notify: "Notify me when the result is out. I'll also get next-match & ₩1,000,000 challenge news.",
submit: "Submit & face the AI",
myPick: "My Pick",
sameAI: (m, exact) =>
`You matched ${m}.${exact ? " Exact score too!" : ""} The other AIs disagree.`,
soloPick: "You disagree with all three AIs — bold pick 🔥",
shareThis: "Share this match",
copyLink: "Copy link",
copied: "Copied ✓",
goldDesc: "Earn points each match by nailing the result & score — the overall #1 wins ₩1,000,000.",
goldBtn: "Go for\n₩1,000,000",
crowdSub: "| What the crowd picked",
finalResult: "Final result",
hit: "Hit ✓",
miss: "Miss",
matchEnded: "Match ended · Final",
cdUntil: "Voting closes in",
cdClosed: "Voting closed",
final: "Final",
group: (g) => `Group ${g}`,
footerNotOfficial:
"Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.",
footerDisc1: "A fan-run prediction game using public match schedules; all picks are independent.",
footerDisc2: "A sports-analysis & entertainment prediction game. No betting or gambling. AI predictions do not guarantee real outcomes.",
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
hook: (a, b) => `AI is split on ${a} vs ${b}`,
},
};
export function dict(lang: Lang): Dict {
return DICT[lang];
}

View File

@ -6,6 +6,21 @@
// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용).
import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types";
import { FEATURED, GROUP_A } from "./schedule";
import { type Lang, teamShort } from "./i18n";
// 체코전 큐레이션 근거 (KO/EN)
const FEATURED_REASONS: Record<Lang, Record<ModelName, string>> = {
ko: {
GPT: "한국 측면 스피드가 한 끗 위",
Claude: "중원 힘싸움이 끝까지 팽팽",
Gemini: "체코 역습 한 방이 매섭다",
},
en: {
GPT: "Korea's wing speed edges it",
Claude: "A tight midfield battle to the end",
Gemini: "Czechia's counter has real bite",
},
};
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
@ -74,28 +89,50 @@ const SCORE_BY_OUTCOME: Record<Outcome, [number, number][]> = {
TEAM_B_WIN: [[0, 1], [1, 2], [0, 2], [1, 3]],
};
function reasonFor(match: Match, outcome: Outcome, seed: number): string {
const A = match.teamA.shortName;
const B = match.teamB.shortName;
const win = outcome === "TEAM_A_WIN" ? A : outcome === "TEAM_B_WIN" ? B : null;
const winPool = win
? [
`${win} 측면 스피드가 한 끗 위`,
`${win} 세트피스 한 방이면 끝`,
`${win} 초반 기세에서 앞선다`,
`${win} 결정력이 승부를 가른다`,
]
: [];
const drawPool = [
"중원 힘싸움이 끝까지 팽팽",
"양 팀 다 한 방 있는 진검승부",
"first goal이 모든 걸 가른다",
];
const pool = win ? winPool : drawPool;
function reasonFor(match: Match, outcome: Outcome, seed: number, lang: Lang): string {
const win =
outcome === "TEAM_A_WIN"
? teamShort(match.teamA, lang)
: outcome === "TEAM_B_WIN"
? teamShort(match.teamB, lang)
: null;
const pools =
lang === "en"
? {
win: win
? [
`${win}'s wing speed edges it`,
`${win} just needs one set piece`,
`${win} starts on the front foot`,
`${win}'s finishing decides it`,
]
: [],
draw: [
"A tight midfield battle to the end",
"Both sides carry a real threat",
"The first goal decides everything",
],
}
: {
win: win
? [
`${win} 측면 스피드가 한 끗 위`,
`${win} 세트피스 한 방이면 끝`,
`${win} 초반 기세에서 앞선다`,
`${win} 결정력이 승부를 가른다`,
]
: [],
draw: [
"중원 힘싸움이 끝까지 팽팽",
"양 팀 다 한 방 있는 진검승부",
"first goal이 모든 걸 가른다",
],
};
const pool = win ? pools.win : pools.draw;
return pool[seed % pool.length];
}
function generatePredictions(match: Match): AIPrediction[] {
function generatePredictions(match: Match, lang: Lang): AIPrediction[] {
const base = hash(match.matchId);
const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length];
return MODELS.map((model, i) => {
@ -112,15 +149,20 @@ function generatePredictions(match: Match): AIPrediction[] {
scoreA,
scoreB,
confidencePct,
reasonShort: reasonFor(match, outcome, seed),
reasonShort: reasonFor(match, outcome, seed, lang),
generatedAt: "2026-06-08",
};
});
}
export function getPredictions(match: Match): AIPrediction[] {
if (match.matchId === FEATURED.matchId) return FEATURED_PREDICTIONS;
return generatePredictions(match);
export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[] {
if (match.matchId === FEATURED.matchId) {
return FEATURED_PREDICTIONS.map((p) => ({
...p,
reasonShort: FEATURED_REASONS[lang][p.model],
}));
}
return generatePredictions(match, lang);
}
export function getCrowd(match: Match): CrowdStats {