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
parent
c66812d954
commit
a8de6c858d
|
|
@ -62,7 +62,6 @@ body {
|
||||||
|
|
||||||
.flag-img {
|
.flag-img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--line-d);
|
border: 1px solid var(--line-d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "TriplePick 2026 | AI 글로벌 축구 승부예측 — GPT vs Claude vs Gemini",
|
title: "TriplePick 2026 | AI 2026 월드컵 승부예측 — GPT vs Claude vs Gemini",
|
||||||
description:
|
description:
|
||||||
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
|
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
|
||||||
applicationName: "TriplePick",
|
applicationName: "TriplePick",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Arena from "@/components/Arena";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import { GROUP_A } from "@/lib/schedule";
|
import { GROUP_A } from "@/lib/schedule";
|
||||||
import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData";
|
import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData";
|
||||||
|
import { parseLang, dict, teamShort } from "@/lib/i18n";
|
||||||
|
|
||||||
// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성
|
// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
|
|
@ -32,42 +33,56 @@ export async function generateMetadata({
|
||||||
|
|
||||||
export default async function MatchPage({
|
export default async function MatchPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ matchId: string }>;
|
params: Promise<{ matchId: string }>;
|
||||||
|
searchParams: Promise<{ lang?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { matchId } = await params;
|
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);
|
const match = GROUP_A.find((m) => m.matchId === matchId);
|
||||||
if (!match) notFound();
|
if (!match) notFound();
|
||||||
|
|
||||||
const predictions = getPredictions(match);
|
const predictions = getPredictions(match, lang);
|
||||||
const crowd = getCrowd(match);
|
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 (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<Hero
|
<Hero
|
||||||
back
|
back
|
||||||
|
lang={lang}
|
||||||
share={{
|
share={{
|
||||||
url: matchUrl(match.matchId),
|
url: matchUrl(match.matchId),
|
||||||
title: `${match.teamA.shortName} vs ${match.teamB.shortName} — TriplePick`,
|
title: `${aShort} vs ${bShort} — TriplePick`,
|
||||||
text: `${match.hookText} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
|
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">
|
<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>
|
<span className="text-[var(--green)]">⫽</span>
|
||||||
{match.hookText}
|
{hook}
|
||||||
<span className="text-[var(--green)]">⫽</span>
|
<span className="text-[var(--green)]">⫽</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<MatchupHUD match={match} />
|
<MatchupHUD match={match} lang={lang} />
|
||||||
<Arena
|
<Arena
|
||||||
match={match}
|
match={match}
|
||||||
predictions={predictions}
|
predictions={predictions}
|
||||||
crowd={crowd}
|
crowd={crowd}
|
||||||
shareUrl={matchUrl(match.matchId)}
|
shareUrl={matchUrl(match.matchId)}
|
||||||
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
<Footer />
|
<Footer lang={lang} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
app/page.tsx
31
app/page.tsx
|
|
@ -1,30 +1,35 @@
|
||||||
import Hero from "@/components/Hero";
|
import Hero from "@/components/Hero";
|
||||||
import ScheduleBoard from "@/components/ScheduleBoard";
|
import ScheduleBoard from "@/components/ScheduleBoard";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
|
import { parseLang, dict } from "@/lib/i18n";
|
||||||
|
|
||||||
// L1. 전체 일정 대시보드 (랜딩 진입)
|
// 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 (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<Hero />
|
<Hero lang={lang} />
|
||||||
|
|
||||||
{/* 기간 안내 + CTA 영역 */}
|
{/* 기간 안내 + CTA 영역 */}
|
||||||
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
|
<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">
|
<p className="whitespace-pre-line text-[14px] font-bold leading-snug">
|
||||||
GPT · Claude · Gemini가 매 경기를
|
{t.introLead}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[14px] font-bold leading-snug text-[var(--green)]">
|
||||||
|
{t.prizeLine1}
|
||||||
<br />
|
<br />
|
||||||
서로 다르게 예측합니다.
|
<span className="whitespace-nowrap">{t.prizeLine2}</span>
|
||||||
<br />
|
|
||||||
<span className="text-[var(--green)]">
|
|
||||||
가장 많이 참여하고, 가장 정확한 최종 우승자에게
|
|
||||||
<br />
|
|
||||||
<span className="whitespace-nowrap">100만원 상금</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScheduleBoard />
|
<ScheduleBoard lang={lang} />
|
||||||
<Footer />
|
<Footer lang={lang} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
|
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
|
||||||
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
|
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
|
||||||
|
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||||||
import type {
|
import type {
|
||||||
Outcome,
|
Outcome,
|
||||||
CrowdStats,
|
CrowdStats,
|
||||||
ModelName,
|
ModelName,
|
||||||
Match,
|
Match,
|
||||||
AIPrediction,
|
AIPrediction,
|
||||||
|
Team,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import Countdown from "./Countdown";
|
import Countdown from "./Countdown";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
@ -28,12 +30,17 @@ export default function Arena({
|
||||||
predictions,
|
predictions,
|
||||||
crowd: initialCrowd,
|
crowd: initialCrowd,
|
||||||
shareUrl,
|
shareUrl,
|
||||||
|
lang = "ko",
|
||||||
}: {
|
}: {
|
||||||
match: Match;
|
match: Match;
|
||||||
predictions: AIPrediction[];
|
predictions: AIPrediction[];
|
||||||
crowd: CrowdStats;
|
crowd: CrowdStats;
|
||||||
shareUrl: string;
|
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 [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||||
const [scoreA, setScoreA] = useState(2);
|
const [scoreA, setScoreA] = useState(2);
|
||||||
const [scoreB, setScoreB] = useState(1);
|
const [scoreB, setScoreB] = useState(1);
|
||||||
|
|
@ -43,6 +50,13 @@ export default function Arena({
|
||||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||||||
|
useEffect(() => {
|
||||||
|
setOutcome(
|
||||||
|
scoreA > scoreB ? "TEAM_A_WIN" : scoreA < scoreB ? "TEAM_B_WIN" : "DRAW",
|
||||||
|
);
|
||||||
|
}, [scoreA, scoreB]);
|
||||||
|
|
||||||
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
|
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const finished = !!match.result;
|
const finished = !!match.result;
|
||||||
|
|
@ -50,12 +64,12 @@ export default function Arena({
|
||||||
const locked = now >= new Date(match.lockAt).getTime();
|
const locked = now >= new Date(match.lockAt).getTime();
|
||||||
const disabled = finished || locked || notOpen;
|
const disabled = finished || locked || notOpen;
|
||||||
const ctaLabel = finished
|
const ctaLabel = finished
|
||||||
? "결과 보기"
|
? t.ctaResult
|
||||||
: notOpen
|
: notOpen
|
||||||
? `투표 오픈 ${kickoffDisplay(match.opensAt)}`
|
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
|
||||||
: locked
|
: locked
|
||||||
? "예측 마감 (경기 시작)"
|
? t.ctaLocked
|
||||||
: "AI보다 잘 맞히기";
|
: t.ctaBeat;
|
||||||
|
|
||||||
const matched = useMemo(() => {
|
const matched = useMemo(() => {
|
||||||
if (!outcome) return [];
|
if (!outcome) return [];
|
||||||
|
|
@ -82,12 +96,17 @@ export default function Arena({
|
||||||
|
|
||||||
const shareText = useMemo(() => {
|
const shareText = useMemo(() => {
|
||||||
if (!outcome) return "";
|
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("·");
|
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
|
return sameAI
|
||||||
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
|
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
|
||||||
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
||||||
}, [outcome, scoreA, scoreB, matched, match]);
|
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -102,7 +121,7 @@ export default function Arena({
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: `${match.teamA.shortName} vs ${match.teamB.shortName} — TriplePick`,
|
title: `${aShort} vs ${bShort} — TriplePick`,
|
||||||
text: shareText,
|
text: shareText,
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
});
|
});
|
||||||
|
|
@ -117,19 +136,22 @@ export default function Arena({
|
||||||
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
|
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
|
||||||
{!finished && !notOpen && (
|
{!finished && !notOpen && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Countdown to={match.lockAt} />
|
<Countdown to={match.lockAt} lang={lang} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
||||||
<section className="sheet mt-4 p-5 text-[var(--ink)]">
|
<section className="sheet mt-4 p-5 text-[var(--ink)]">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h2 className="text-[22px] font-extrabold">AI의 예측 대결</h2>
|
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{predictions.map((p) => {
|
{predictions.map((p) => {
|
||||||
const wp = winProb(match, p);
|
const wp = winProb(match, p);
|
||||||
|
const wpLabel = wp.team
|
||||||
|
? t.winProb(teamShort(wp.team, lang))
|
||||||
|
: t.drawOdds;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.model}
|
key={p.model}
|
||||||
|
|
@ -151,7 +173,7 @@ export default function Arena({
|
||||||
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
|
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="text-[12px] font-bold text-[var(--ink-muted)]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,17 +194,17 @@ export default function Arena({
|
||||||
{/* 당신의 선택 */}
|
{/* 당신의 선택 */}
|
||||||
<div className="mt-5 flex items-center justify-between">
|
<div className="mt-5 flex items-center justify-between">
|
||||||
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
|
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
|
||||||
Your Pick · 내 예측
|
{t.yourPickLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[20px] font-extrabold">당신의 선택</span>
|
<span className="text-[20px] font-extrabold">{t.yourChoice}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["TEAM_A_WIN", `${match.teamA.shortName} 승`],
|
["TEAM_A_WIN", `${aShort} ${t.win}`],
|
||||||
["DRAW", "무승부"],
|
["DRAW", t.drawLabel],
|
||||||
["TEAM_B_WIN", `${match.teamB.shortName} 승`],
|
["TEAM_B_WIN", `${bShort} ${t.win}`],
|
||||||
] as [Outcome, string][]
|
] as [Outcome, string][]
|
||||||
).map(([val, label]) => (
|
).map(([val, label]) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -201,21 +223,20 @@ export default function Arena({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
|
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ===== 종료된 경기: 결과 보기 ===== */}
|
{/* ===== 종료된 경기: 결과 보기 ===== */}
|
||||||
{finished && match.result && (
|
{finished && match.result && (
|
||||||
<section className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
|
<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">
|
<div className="mt-1 text-[26px] font-extrabold">
|
||||||
{match.teamA.shortName} {match.result.scoreA}-{match.result.scoreB}{" "}
|
{aShort} {match.result.scoreA}-{match.result.scoreB} {bShort}
|
||||||
{match.teamB.shortName}
|
|
||||||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||||||
{outcomeLabel(match, match.result.outcome)}
|
{outcomeLabel(match, match.result.outcome, lang)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-1.5">
|
<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]">
|
<div key={p.model} className="flex items-center justify-between text-[13px]">
|
||||||
<span className="font-bold text-white/85">{p.model}</span>
|
<span className="font-bold text-white/85">{p.model}</span>
|
||||||
<span className={hit ? "font-bold text-[var(--green)]" : "text-white/45"}>
|
<span className={hit ? "font-bold text-[var(--green)]" : "text-white/45"}>
|
||||||
{hit ? "적중 ✓" : "빗나감"}
|
{hit ? t.hit : t.miss}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -253,7 +274,7 @@ export default function Arena({
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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)]"
|
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)]">
|
<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)}
|
onChange={(e) => setNotify(e.target.checked)}
|
||||||
className="mt-0.5 accent-[var(--mint)]"
|
className="mt-0.5 accent-[var(--mint)]"
|
||||||
/>
|
/>
|
||||||
경기 결과가 나오면 알려주세요. 다음 경기·100만원 챌린지 소식도 받겠습니다.
|
{t.notify}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={confirmSubmit}
|
onClick={confirmSubmit}
|
||||||
disabled={!EMAIL_RE.test(email.trim())}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -278,30 +299,27 @@ export default function Arena({
|
||||||
{/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */}
|
{/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */}
|
||||||
{!finished && step === "done" && outcome && (
|
{!finished && step === "done" && outcome && (
|
||||||
<div className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
|
<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">
|
<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)]">
|
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||||||
{outcomeLabel(match, outcome)}
|
{outcomeLabel(match, outcome, lang)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
|
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
|
||||||
{matched.length > 0 ? (
|
{matched.length > 0
|
||||||
<>
|
? t.sameAI(
|
||||||
당신은 <b>{matched.map((m) => m.model).join("·")}</b>와 같은 선택입니다.
|
matched.map((m) => m.model).join("·"),
|
||||||
{matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "}
|
matched.some((m) => m.exact),
|
||||||
나머지 AI는 생각이 다릅니다.
|
)
|
||||||
</>
|
: t.soloPick}
|
||||||
) : (
|
|
||||||
<>당신은 AI 셋 모두와 다른 선택! 소신 픽 🔥</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2.5">
|
<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 onClick={nativeShare} className="btn-mint rounded-xl py-3 text-[15px] font-bold active:scale-[0.99]">
|
||||||
이 경기 공유하기
|
{t.shareThis}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={copyLink} className="rounded-xl border border-[var(--line-d)] py-3 text-[15px] font-bold active:scale-[0.99]">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -316,25 +334,20 @@ export default function Arena({
|
||||||
<span>★</span>
|
<span>★</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
|
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
|
||||||
경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원
|
{t.goldDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="gold-btn shrink-0 rounded-xl px-4 py-3.5 text-[14px] font-extrabold leading-tight active:scale-[0.98]">
|
<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]">
|
||||||
100만 원에
|
{t.goldBtn}
|
||||||
<br />
|
|
||||||
도전하기
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
|
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<div className="mb-1 text-[20px] font-extrabold">
|
<div className="mb-2.5 text-[20px] font-extrabold">
|
||||||
Crowd Pick{" "}
|
Crowd Pick{" "}
|
||||||
<span className="text-[14px] text-[var(--ink-muted)]">| 참여자들의 선택</span>
|
<span className="text-[14px] text-[var(--ink-muted)]">{t.crowdSub}</span>
|
||||||
</div>
|
|
||||||
<div className="mb-2.5 text-[11px] text-[var(--ink-muted)]">
|
|
||||||
참여자 투표 비율입니다 (AI 승리 확률과 다릅니다)
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
|
<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 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" />
|
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, crowd.total)} tone="b" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
|
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
|
||||||
{crowd.total.toLocaleString()}명 참여
|
{t.joined(crowd.total.toLocaleString())}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|
@ -403,7 +416,7 @@ function CrowdSeg({
|
||||||
value,
|
value,
|
||||||
tone,
|
tone,
|
||||||
}: {
|
}: {
|
||||||
team?: import("@/lib/types").Team;
|
team?: Team;
|
||||||
value: number;
|
value: number;
|
||||||
tone: "a" | "draw" | "b";
|
tone: "a" | "draw" | "b";
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
|
|
||||||
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
|
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
|
||||||
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
|
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
|
||||||
export default function Countdown({
|
export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang }) {
|
||||||
to,
|
const t = dict(lang);
|
||||||
label = "투표 종료까지",
|
const label = t.cdUntil;
|
||||||
}: {
|
|
||||||
to: string;
|
|
||||||
label?: string;
|
|
||||||
}) {
|
|
||||||
const [left, setLeft] = useState<number | null>(null);
|
const [left, setLeft] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -26,7 +23,7 @@ export default function Countdown({
|
||||||
return (
|
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">
|
<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)]">
|
<span className="text-[12px] font-semibold text-[var(--ink-muted)]">
|
||||||
{ended ? "투표가 마감되었습니다" : label}
|
{ended ? t.cdClosed : label}
|
||||||
</span>
|
</span>
|
||||||
{!ended && (
|
{!ended && (
|
||||||
<span
|
<span
|
||||||
|
|
@ -34,20 +31,21 @@ export default function Countdown({
|
||||||
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
|
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{left === null ? "--:--:--" : fmt(left)}
|
{left === null ? "--:--:--" : fmt(left, lang)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(ms: number): string {
|
function fmt(ms: number, lang: Lang): string {
|
||||||
const s = Math.floor(ms / 1000);
|
const s = Math.floor(ms / 1000);
|
||||||
const d = Math.floor(s / 86400);
|
const d = Math.floor(s / 86400);
|
||||||
const h = Math.floor((s % 86400) / 3600);
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
const m = Math.floor((s % 3600) / 60);
|
const m = Math.floor((s % 3600) / 60);
|
||||||
const sec = s % 60;
|
const sec = s % 60;
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
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)}`;
|
return `${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<footer className="mt-6 text-center">
|
<footer className="mt-6 text-center">
|
||||||
<p className="text-[10.5px] font-semibold text-white/75">
|
<p className="text-[10.5px] font-semibold text-white/75">
|
||||||
Independent AI prediction event. Not official.
|
{t.footerNotOfficial}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[10.5px] leading-relaxed text-white/60">
|
<p className="mt-1 text-[10.5px] leading-relaxed text-white/60">
|
||||||
본 서비스는 독립적인 AI 예측 이벤트이며, 공식·조작·후원·운영과
|
{t.footerDisc1}
|
||||||
무관합니다.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-white/55">
|
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-white/55">
|
||||||
<p>
|
<p>{t.footerDisc2}</p>
|
||||||
스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지
|
<p>{t.footerDisc3}</p>
|
||||||
않습니다. AI 예측은 실제 결과를 보장하지 않습니다.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도
|
|
||||||
약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-[10px] text-white/70">
|
<div className="mt-3 text-[10px] text-white/70">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
|
import LangSwitch from "./LangSwitch";
|
||||||
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
|
|
||||||
type Share = { url: string; title: string; text: string };
|
type Share = { url: string; title: string; text: string };
|
||||||
|
|
||||||
|
|
@ -7,31 +9,42 @@ type Share = { url: string; title: string; text: string };
|
||||||
export default function Hero({
|
export default function Hero({
|
||||||
back = false,
|
back = false,
|
||||||
share,
|
share,
|
||||||
|
lang = "ko",
|
||||||
}: {
|
}: {
|
||||||
back?: boolean;
|
back?: boolean;
|
||||||
share?: Share;
|
share?: Share;
|
||||||
|
lang?: Lang;
|
||||||
}) {
|
}) {
|
||||||
|
const t = dict(lang);
|
||||||
|
const home = lang === "en" ? "/?lang=en" : "/";
|
||||||
return (
|
return (
|
||||||
<header className="pt-5 text-center">
|
<header className="pt-5 text-center">
|
||||||
{back && (
|
{back && (
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Link
|
<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"
|
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>
|
</Link>
|
||||||
{share && <ShareButton {...share} />}
|
{share && (
|
||||||
|
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="font-impact text-[44px] leading-none tracking-tight">
|
<div className="font-impact text-[44px] leading-none tracking-tight">
|
||||||
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
|
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[16px] font-extrabold text-[var(--green)]">
|
<div className="relative mt-2">
|
||||||
|
<div className="text-[16px] font-extrabold text-[var(--green)]">
|
||||||
AI Prediction Arena
|
AI Prediction Arena
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,15 @@
|
||||||
import type { Match } from "@/lib/types";
|
import type { Match } from "@/lib/types";
|
||||||
import { kickoffDisplay } from "@/lib/format";
|
import { kickoffDisplay } from "@/lib/format";
|
||||||
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
import BallIcon from "./BallIcon";
|
import BallIcon from "./BallIcon";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
||||||
export default function MatchupHUD({ match }: { match: Match }) {
|
export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
|
||||||
const { group, roundLabel } = match;
|
const t = dict(lang);
|
||||||
|
const { group } = match;
|
||||||
const finished = !!match.result;
|
const finished = !!match.result;
|
||||||
return (
|
return (
|
||||||
<section className="mt-6">
|
<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
|
<div
|
||||||
className="rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5"
|
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}
|
{match.teamB.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
|
<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>
|
||||||
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
|
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -71,7 +55,7 @@ export default function MatchupHUD({ match }: { match: Match }) {
|
||||||
|
|
||||||
{finished && (
|
{finished && (
|
||||||
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
|
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
|
||||||
경기 종료 · 최종 결과
|
{t.matchEnded}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,28 @@ import Link from "next/link";
|
||||||
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
|
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
|
||||||
import { getPredictions, getCrowd } from "@/lib/mockData";
|
import { getPredictions, getCrowd } from "@/lib/mockData";
|
||||||
import { dateHeading, timeOnly } from "@/lib/format";
|
import { dateHeading, timeOnly } from "@/lib/format";
|
||||||
|
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
|
||||||
import type { Match } from "@/lib/types";
|
import type { Match } from "@/lib/types";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
||||||
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
|
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
|
||||||
|
|
||||||
const PHASE_META: Record<MatchPhase, { label: string; cls: string }> = {
|
const PHASE_CLS: Record<MatchPhase, string> = {
|
||||||
open: { label: "투표 중", cls: "border-[#94FBE0] text-[#94FBE0]" },
|
open: "border-[#94FBE0] text-[#94FBE0]",
|
||||||
scheduled: { label: "오픈 예정", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" },
|
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
|
||||||
locked: { label: "투표 마감", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" },
|
locked: "border-[var(--line-d)] text-[var(--ink-muted)]",
|
||||||
finished: { label: "종료", cls: "border-white/15 text-white/55" },
|
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();
|
const groups = matchesByDate();
|
||||||
return (
|
return (
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<div className="mb-3 flex items-baseline gap-2.5">
|
<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">
|
<span className="whitespace-nowrap text-[12px] text-white/55">
|
||||||
투표하고 싶은 경기를 선택해주세요
|
{t.schedGuide}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -29,11 +31,11 @@ export default function ScheduleBoard() {
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<div key={g.date}>
|
<div key={g.date}>
|
||||||
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
|
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
|
||||||
{dateHeading(g.date)}
|
{dateHeading(g.date, lang)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{g.matches.map((m) => (
|
{g.matches.map((m) => (
|
||||||
<MatchCard key={m.matchId} match={m} />
|
<MatchCard key={m.matchId} match={m} lang={lang} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 phase = matchPhase(match);
|
||||||
const meta = PHASE_META[phase];
|
const preds = getPredictions(match, lang);
|
||||||
const preds = getPredictions(match);
|
|
||||||
const crowd = getCrowd(match);
|
const crowd = getCrowd(match);
|
||||||
|
|
||||||
// AI 픽 갈림 요약
|
// AI 픽 갈림 요약
|
||||||
|
|
@ -57,42 +59,45 @@ function MatchCard({ match }: { match: Match }) {
|
||||||
else tally.b++;
|
else tally.b++;
|
||||||
}
|
}
|
||||||
const split: string[] = [];
|
const split: string[] = [];
|
||||||
if (tally.a) split.push(`${match.teamA.shortName} ${tally.a}`);
|
if (tally.a) split.push(`${teamShort(match.teamA, lang)} ${tally.a}`);
|
||||||
if (tally.d) split.push(`무 ${tally.d}`);
|
if (tally.d) split.push(`${t.draw} ${tally.d}`);
|
||||||
if (tally.b) split.push(`${match.teamB.shortName} ${tally.b}`);
|
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
|
||||||
|
|
||||||
|
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<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]"
|
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]">
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
<span className="font-mono font-bold text-white/70">
|
<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>
|
||||||
<span className={`rounded-md border px-2 py-0.5 font-semibold ${meta.cls}`}>
|
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
|
||||||
{meta.label}
|
{t.phase[phase]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
|
<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>
|
</div>
|
||||||
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
|
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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" />
|
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
|
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
|
||||||
<span>
|
<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>
|
||||||
<span className="flex items-center gap-2">
|
<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 className="text-[#94FBE0]">→</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,14 @@ export default function ShareButton({
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
|
label = "투표 공유하기",
|
||||||
|
copiedLabel = "링크 복사됨 ✓",
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
label?: string;
|
||||||
|
copiedLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -37,7 +41,7 @@ export default function ShareButton({
|
||||||
onClick={onShare}
|
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"
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Team } from "@/lib/types";
|
import type { Team } from "@/lib/types";
|
||||||
|
|
||||||
// 모든 경기에 일반화된 국기 렌더.
|
// 모든 경기에 일반화된 국기 렌더.
|
||||||
// KOR = 실제 png 자산, CZE = 인라인 SVG(기존 디자인 유지), 그 외 = 이모지 박스.
|
// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
|
||||||
export default function TeamFlag({
|
export default function TeamFlag({
|
||||||
team,
|
team,
|
||||||
className = "",
|
className = "",
|
||||||
|
|
@ -22,8 +22,8 @@ export default function TeamFlag({
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 6 4"
|
viewBox="0 0 6 4"
|
||||||
className={`rounded-lg border border-[var(--line-d)] ${className}`}
|
className={`border border-[var(--line-d)] ${className}`}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
aria-label={team.name}
|
aria-label={team.name}
|
||||||
>
|
>
|
||||||
<rect width="6" height="2" y="0" fill="#ffffff" />
|
<rect width="6" height="2" y="0" fill="#ffffff" />
|
||||||
|
|
@ -32,13 +32,14 @@ export default function TeamFlag({
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 그 외 국가 — 이모지 국기 (자산 추가 전 fallback)
|
// 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
|
||||||
return (
|
return (
|
||||||
<span
|
<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}
|
aria-label={team.name}
|
||||||
>
|
>
|
||||||
<span className="leading-none" style={{ fontSize: "1.7em" }}>
|
<span className="leading-none" style={{ fontSize: "92cqh" }}>
|
||||||
{team.flag}
|
{team.flag}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,32 @@
|
||||||
import type { Outcome, Match, AIPrediction, Team } from "./types";
|
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 {
|
export function outcomeLabel(match: Match, o: Outcome, lang: Lang = "ko"): string {
|
||||||
if (o === "TEAM_A_WIN") return `${match.teamA.shortName} 승`;
|
const d = dict(lang);
|
||||||
if (o === "TEAM_B_WIN") return `${match.teamB.shortName} 승`;
|
if (o === "TEAM_A_WIN") return `${teamShort(match.teamA, lang)} ${d.win}`;
|
||||||
return "무승부";
|
if (o === "TEAM_B_WIN") return `${teamShort(match.teamB, lang)} ${d.win}`;
|
||||||
|
return d.drawLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// D3: 확률 = "이긴다고 예측한 팀의 승리 확률". 무승부 예측이면 "무승부 가능성".
|
// D3: 확률 = 이긴다고 예측한 팀의 승리 확률. 라벨은 호출부에서 dict로 구성.
|
||||||
export function winProb(
|
export function winProb(
|
||||||
match: Match,
|
match: Match,
|
||||||
p: Pick<AIPrediction, "outcome" | "confidencePct">,
|
p: Pick<AIPrediction, "outcome" | "confidencePct">,
|
||||||
): { team: Team | null; label: string; pct: number } {
|
): { team: Team | null; pct: number } {
|
||||||
if (p.outcome === "TEAM_A_WIN")
|
if (p.outcome === "TEAM_A_WIN") return { team: match.teamA, pct: p.confidencePct };
|
||||||
return { team: match.teamA, label: `${match.teamA.shortName} 승리 확률`, pct: p.confidencePct };
|
if (p.outcome === "TEAM_B_WIN") return { team: match.teamB, pct: p.confidencePct };
|
||||||
if (p.outcome === "TEAM_B_WIN")
|
return { team: null, pct: p.confidencePct };
|
||||||
return { team: match.teamB, label: `${match.teamB.shortName} 승리 확률`, pct: p.confidencePct };
|
|
||||||
return { team: null, label: "무승부 가능성", pct: p.confidencePct };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "6월 12일 (금)" — 대시보드 날짜 그룹 헤더
|
// "6월 12일 (금)" / "Jun 12 (Fri)" — 대시보드 날짜 그룹 헤더
|
||||||
export function dateHeading(iso: string): string {
|
export function dateHeading(iso: string, lang: Lang = "ko"): string {
|
||||||
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
|
||||||
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
|
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
if (!dm) return iso;
|
if (!dm) return iso;
|
||||||
const [, y, mo, d] = dm;
|
const [, y, mo, d] = dm;
|
||||||
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})`;
|
return lang === "en"
|
||||||
|
? `${monthEn(+mo)} ${+d} (${dow})`
|
||||||
|
: `${+mo}월 ${+d}일 (${dow})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "11:00" — 경기 카드 시간
|
// "11:00" — 경기 카드 시간
|
||||||
|
|
@ -40,14 +41,12 @@ export function dateKey(iso: string): string {
|
||||||
return m ? m[1] : iso;
|
return m ? m[1] : iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kickoffDisplay(iso: string): string {
|
// "6.12(금) 11:00 KST" — 요일명만 로케일 적용
|
||||||
// iso는 KST(+09:00). 런타임 TZ에 무관하게 ISO 문자열을 직접 파싱한다.
|
export function kickoffDisplay(iso: string, lang: Lang = "ko"): string {
|
||||||
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
|
||||||
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||||
if (!dm) return iso;
|
if (!dm) return iso;
|
||||||
const [, y, mo, d, hh, mm] = dm;
|
const [, y, mo, d, hh, mm] = dm;
|
||||||
// 요일은 달력 날짜 기준(UTC Date로 계산하면 TZ 무관)
|
const dow = dayName(lang, new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay());
|
||||||
const dow = days[new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay()];
|
|
||||||
return `${+mo}.${+d}(${dow}) ${hh}:${mm} KST`;
|
return `${+mo}.${+d}(${dow}) ${hh}:${mm} KST`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,21 @@
|
||||||
// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용).
|
// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용).
|
||||||
import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types";
|
import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types";
|
||||||
import { FEATURED, GROUP_A } from "./schedule";
|
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)
|
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]],
|
TEAM_B_WIN: [[0, 1], [1, 2], [0, 2], [1, 3]],
|
||||||
};
|
};
|
||||||
|
|
||||||
function reasonFor(match: Match, outcome: Outcome, seed: number): string {
|
function reasonFor(match: Match, outcome: Outcome, seed: number, lang: Lang): string {
|
||||||
const A = match.teamA.shortName;
|
const win =
|
||||||
const B = match.teamB.shortName;
|
outcome === "TEAM_A_WIN"
|
||||||
const win = outcome === "TEAM_A_WIN" ? A : outcome === "TEAM_B_WIN" ? B : null;
|
? teamShort(match.teamA, lang)
|
||||||
const winPool = win
|
: 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} 세트피스 한 방이면 끝`,
|
||||||
`${win} 초반 기세에서 앞선다`,
|
`${win} 초반 기세에서 앞선다`,
|
||||||
`${win} 결정력이 승부를 가른다`,
|
`${win} 결정력이 승부를 가른다`,
|
||||||
]
|
]
|
||||||
: [];
|
: [],
|
||||||
const drawPool = [
|
draw: [
|
||||||
"중원 힘싸움이 끝까지 팽팽",
|
"중원 힘싸움이 끝까지 팽팽",
|
||||||
"양 팀 다 한 방 있는 진검승부",
|
"양 팀 다 한 방 있는 진검승부",
|
||||||
"first goal이 모든 걸 가른다",
|
"first goal이 모든 걸 가른다",
|
||||||
];
|
],
|
||||||
const pool = win ? winPool : drawPool;
|
};
|
||||||
|
const pool = win ? pools.win : pools.draw;
|
||||||
return pool[seed % pool.length];
|
return pool[seed % pool.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePredictions(match: Match): AIPrediction[] {
|
function generatePredictions(match: Match, lang: Lang): AIPrediction[] {
|
||||||
const base = hash(match.matchId);
|
const base = hash(match.matchId);
|
||||||
const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length];
|
const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length];
|
||||||
return MODELS.map((model, i) => {
|
return MODELS.map((model, i) => {
|
||||||
|
|
@ -112,15 +149,20 @@ function generatePredictions(match: Match): AIPrediction[] {
|
||||||
scoreA,
|
scoreA,
|
||||||
scoreB,
|
scoreB,
|
||||||
confidencePct,
|
confidencePct,
|
||||||
reasonShort: reasonFor(match, outcome, seed),
|
reasonShort: reasonFor(match, outcome, seed, lang),
|
||||||
generatedAt: "2026-06-08",
|
generatedAt: "2026-06-08",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPredictions(match: Match): AIPrediction[] {
|
export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[] {
|
||||||
if (match.matchId === FEATURED.matchId) return FEATURED_PREDICTIONS;
|
if (match.matchId === FEATURED.matchId) {
|
||||||
return generatePredictions(match);
|
return FEATURED_PREDICTIONS.map((p) => ({
|
||||||
|
...p,
|
||||||
|
reasonShort: FEATURED_REASONS[lang][p.model],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return generatePredictions(match, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCrowd(match: Match): CrowdStats {
|
export function getCrowd(match: Match): CrowdStats {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue