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 {
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-d);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
31
app/page.tsx
31
app/page.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 미사용).
|
||||
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
|
||||
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} 결정력이 승부를 가른다`,
|
||||
]
|
||||
: [];
|
||||
const drawPool = [
|
||||
: [],
|
||||
draw: [
|
||||
"중원 힘싸움이 끝까지 팽팽",
|
||||
"양 팀 다 한 방 있는 진검승부",
|
||||
"first goal이 모든 걸 가른다",
|
||||
];
|
||||
const pool = win ? winPool : drawPool;
|
||||
],
|
||||
};
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue