Compare commits

...

17 Commits

Author SHA1 Message Date
Haewon Kam 996d5faaf4 Refine ranking placement, visibility, and consistency
- Move cumulative ranking above the gold ₩1M card (ranking → challenge flow)
- Drop redundant "전체 보기" link (inline folding shows full Top 10);
  /leaderboard route kept for direct access
- Make fold toggle purple + match its size to the tab buttons; enlarge
  title/tab/row text for readability
- Match email input & submit button radius to the ranking box (rounded-xl)
- Leaderboard full page: top button = "투표로 돌아가기" (history back, home fallback)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:47:57 +09:00
Haewon Kam 965bd374c3 Add cumulative ranking (TOP 10, voters/AI tabs, folding)
- New lib/leaderboard.ts: fetchLeaderboard(tab) contract + maskEmailId
  (PII) + deterministic demo; backend swaps body for getLeaderboard
- New components/Leaderboard.tsx: 2 tabs (참가자/AI 모델), folded by
  default with 랭킹 보기/접기, top-3 purple highlight, id + points
- Place folding ranking right after the gold ₩1M card on match page
- /leaderboard full page renders the same component expanded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:32:26 +09:00
Haewon Kam b69b1dbe97 Add "my past predictions" (email-based, no login) + cleanup
- New lib/myPredictions.ts: typed contract (fetch/save/remember/recall)
  with localStorage demo impl; backend dev swaps the two bodies for
  Firebase callables (getMyPredictions / submitPrediction)
- Arena: recall email on revisit, show my picks sorted newest-first,
  prefill + highlight the current match, upsert per match
- Fold list to 1 row by default with 더보기/접기 toggle (handles 10s of votes)
- Result badges: hit = purple (--share), miss = gray; date until scored
- Remove unused orange --claude token (orange-ban rule; logo image stays)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:32:17 +09:00
Haewon Kam cbe0e8e01d Right-size submit CTA + match input to card shape
- Submit button height/text now match the bottom "다른 경기 투표하기"
  CTA (py-3, text-15) — was oversized (py-5, text-19)
- Keep rounded-2xl so roundness stays consistent with the green card
- Email input matched to same rounded-2xl shape and height

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:56:27 +09:00
Haewon Kam cbab4a102a Make email input border purple (1px) so it reads before input
Dark --line-d border was nearly invisible on the dark card; switch to
a thin purple (--share) border + soft purple ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:46:06 +09:00
Haewon Kam c8431e65f3 Unify vote-submit into single always-visible CTA card
- Collapse pick→form two-step into one card: email gate header +
  input + benefit checkbox + single purple submit button
- Purple (--share) for user submit action vs mint for AI/system
- Keep disabled CTA visible (opacity .72 + glow) to drive clicks
- Unify copy with header: "투표 제출하고 AI와 겨루기"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:42:57 +09:00
Haewon Kam 7d4d50af40 Enlarge header buttons 1.5x + add bottom "다른 경기 투표하기" CTA (ADO2 purple)
- Hero "전체 일정"·ShareButton "투표 공유하기" 1.5배 (12→18px, 패딩 확대)
- 상세 페이지 하단(Crowd 아래·푸터 위)에 "다른 경기 투표하기 →" CTA 추가
  - ADO2 광고 버튼 동일 사양: 퍼플 #A65EFF · 라운드 풀 · 글로우 · 화살표
  - 텍스트 15px, 전체 일정(대시보드) 이동
- i18n moreMatches (KO/EN)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:13:13 +09:00
Haewon Kam 6c3a47b6ba Demo: mark opener as finished with 2-1 Mexico win on schedule board
- schedule.ts: opener(MEX vs RSA) status finished + result 2-1 (TEAM_A_WIN)
- i18n: phase.finished label 종료→투표종료 / Ended→Voting closed
- ScheduleBoard: show score around VS + winner label next to round when result exists

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:20:14 +09:00
Haewon Kam 0c91c902a8 Docs: ban all emoji in design/video/landing (DESIGN.md §7)
Decorative/pictogram emoji (👆🏆🟠 etc.) forbidden — use color,
typography, or real SVG/PNG icons. Typographic glyphs (→ ★ ✓ ·) allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:04:46 +09:00
Haewon Kam 7a375ee32f Switch 너→당신 (formal address) + handle @triplepick_ai→@triplepickai
- 너 reads as condescending (하대) in Korean, esp. for older viewers we
  optimized readability for; unify on 당신 to match product UI voice.
- Remove underscore from social handle across footer/before page.
- User accent on /before: amber 🟠 → brand point purple 🟣.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:59:10 +09:00
Haewon Kam 596ed9c094 Rebrand 월드컵/World Cup → 글로벌 축구 축전/Global Football Festival
Trademark avoidance: replace all branding/marketing uses across title,
hero pill (ko/en), before page, schedule comment. Disclaimers keep the
protective FIFA reference but drop the event mark (→ "공식 축구 대회" /
"any official football tournament").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:28:24 +09:00
Haewon Kam 6a094debbc Lock 2026-06-10 canonical Korea–Czechia prediction snapshot
GPT 무 1-1 (45%) / Claude 무 1-1 (55%) / Gemini 한국 2-1 (60%).
Claude flipped draw→win earlier; re-ran APIs and froze today's
immutable snapshot. Also corrects Gemini label 3.5-flash→2.5-flash
(actual model used). Landing now matches video plan + PR source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:27:42 +09:00
Haewon Kam 9de27dafd8 Show prediction basis (datetime + model versions) next to "AI 예측 대결"
- "AI의 예측 대결" 우측에 예측 기준(생성 시각) + 실제 모델 버전 표기
  - 라벨 "예측 기준 / As of" + 2026.06.10 00:00 KST (매일 0시 갱신 기준)
  - gpt-5.5 · claude-opus-4-8 · gemini-3.5-flash (MODEL_VERSIONS)
- 가운데 정렬, 가시성↑(11px), 본문 폰트 통일

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:06:38 +09:00
Haewon Kam f29dbd0454 Unify all flags to rectangular SVG (fix waving emoji inconsistency)
- 원인: MEX·RSA는 이모지 국기(OS별 휘날림/광택), KOR·CZE만 실제 이미지 → 불일치
- 해결: 4개국 전부 flagcdn 직사각형 SVG(public/icons/flags) + object-cover로 통일, 이모지 제거
- 미등록 국가는 이모지 fallback 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:35:14 +09:00
Haewon Kam 9b35a759a9 Inject real 3-model predictions for remaining 5 Group A matches (local only)
- CURATED 맵: MEX-RSA, CZE-RSA, MEX-KOR, CZE-MEX, RSA-KOR 5경기 실제 예측
  - GPT(gpt-5.5)·Gemini(gemini-3.5-flash) 실제 API + Claude(opus-4-8) 직접
  - MEX-KOR은 GPT=멕시코 / Claude·Gemini=무승부로 갈림
- getPredictions가 CURATED 우선 사용, 없으면 결정론 생성
- ⚠️ 로컬+Vercel만 반영 (Gitea push 안 함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:26:44 +09:00
Haewon Kam 2fe60ab0cd Track .env.example (template, no secrets) with AI model config
- .gitignore: .env* 무시 유지 + !.env.example 예외(템플릿 추적)
- .env.example: 3 AI 모델 키 슬롯 + 최신 모델 ID + Gemini 함정 노트

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:22:28 +09:00
Haewon Kam 0a15906afb Document AI prediction generation (3 models) + .env.example AI keys
- docs/AI-PREDICTIONS.md: 최신 모델 ID(gpt-5.5/claude-opus-4-8/gemini-3.5-flash), 호출법, Gemini generationConfig 함정, 체코전 실제 출력
- .env.example: 3 AI 모델 키 슬롯 + 모델 ID + Gemini 주의 노트 (시크릿 없음)
- (목업 데이터 mockData.ts는 35414a8에서 실제 예측 반영 완료)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:21:35 +09:00
26 changed files with 818 additions and 115 deletions

38
.env.example Normal file
View File

@ -0,0 +1,38 @@
# ===== TriplePick 환경 변수 (.env.local 로 복사 후 채우기) =====
# 새 Firebase 프로젝트 생성 후 웹앱 config 6키 (Firebase 콘솔 > 프로젝트 설정 > 웹앱)
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
# (선택) GA4
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
NEXT_PUBLIC_GA_ID=
# Cloud Functions 호출 리전 (서울 권장)
NEXT_PUBLIC_FUNCTIONS_REGION=asia-northeast3
# 공유/딥링크 베이스 URL
NEXT_PUBLIC_SITE_URL=https://triplepick-web.vercel.app
# ===== 서버 전용 (Cloud Functions 환경 · 클라이언트 노출 금지) =====
# 결과 알림 이메일 (Resend)
RESEND_API_KEY=
RESEND_FROM="TriplePick <noreply@triplepick.ai>"
# 관리자 HTTP 엔드포인트 보호용 베어러 토큰 (경기/예측/결과 입력)
ADMIN_API_TOKEN=
# 시드 스크립트용 서비스 계정 키 경로 (firebase-admin)
GOOGLE_APPLICATION_CREDENTIALS=./serviceAccount.json
# ===== AI 예측 생성 (3모델 · 상세 docs/AI-PREDICTIONS.md) =====
# 매일 KST 0시 남은 경기 예측 생성. 모델 ID는 각 콘솔 GET /models 로 최신 확인.
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.5
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-opus-4-8
GEMINI_API_KEY=
GEMINI_MODEL=gemini-3.5-flash
# ⚠️ Gemini는 generationConfig(responseMimeType/thinkingConfig) 주면 빈 응답 → 평문 본문 + 응답 JSON 파싱

3
.gitignore vendored
View File

@ -30,8 +30,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# env files (secrets ignored; template tracked)
.env*
!.env.example
# vercel
.vercel

View File

@ -157,7 +157,7 @@ export default function Before() {
{/* Your pick */}
<section id="pick" style={{ marginTop: 20, scrollMarginTop: 80 }}>
<h2 style={{ fontSize: 14, fontWeight: 800, marginBottom: 8 }}> <span style={{ color: C.amber }}>🟠</span></h2>
<h2 style={{ fontSize: 14, fontWeight: 800, marginBottom: 8 }}> <span style={{ color: "#a65eff" }}>🟣</span></h2>
<div style={{ ...card, padding: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, background: C.bg2, borderRadius: 8, padding: 4 }}>
{([["A", "한국 승"], ["D", "무승부"], ["B", "체코 승"]] as [string, string][]).map(([v, l]) => (
@ -210,7 +210,7 @@ export default function Before() {
{/* Prize */}
<section style={{ marginTop: 20 }}>
<div style={{ ...card, padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🏆</span><h2 style={{ fontSize: 14, fontWeight: 800 }}> 100 </h2></div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🏆</span><h2 style={{ fontSize: 14, fontWeight: 800 }}> 100 </h2></div>
<p style={{ marginTop: 6, fontSize: 12, color: C.muted, lineHeight: 1.6 }}>AI 100 . , .</p>
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, background: `${C.green}1a`, color: C.green, border: `1px solid ${C.green}66` }}>100 </button>
</div>
@ -220,7 +220,7 @@ export default function Before() {
<footer style={{ marginTop: 32, borderTop: `1px solid ${C.line}`, paddingTop: 20 }}>
<p style={{ fontSize: 11, color: C.muted, lineHeight: 1.6 }}> AIO2O AI . GPT·Claude·Gemini .</p>
<p style={{ fontSize: 10, color: `${C.muted}cc`, marginTop: 8, lineHeight: 1.6 }}> · · . AI .</p>
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}><span>© 2026 TriplePick · AIO2O</span><span>@triplepick_ai</span></div>
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}><span>© 2026 TriplePick · AIO2O</span><span>@triplepickai</span></div>
</footer>
</main>
</div>

View File

@ -21,7 +21,7 @@
--gold-btn-2: #15a99b; /* 틸그린 */
--gpt: #15a99b; /* 틸그린 (teal green) */
--claude: #e8814a;
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
--gemini: #5b8cff;
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
@ -130,6 +130,15 @@ body {
0 10px 28px rgba(133, 252, 168, 0.28);
}
/* 사용자 액션(투표 제출) — 브랜드 포인트 퍼플 */
.btn-share {
background: var(--share);
color: #fff;
box-shadow:
0 0 0 1px rgba(166, 94, 255, 0.5),
0 12px 30px rgba(166, 94, 255, 0.35);
}
.gold-card {
background:
linear-gradient(180deg, rgba(94, 240, 180, 0.08), rgba(21, 169, 155, 0.03)),

View File

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

View File

@ -1,25 +1,31 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
import Leaderboard from "@/components/Leaderboard";
import { parseLang, dict } from "@/lib/i18n";
// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub.
export const metadata: Metadata = {
title: "랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 곧 공개됩니다.",
title: "누적 랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 참가자·AI 모델 TOP 10.",
};
export default function LeaderboardPage() {
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<{ lang?: string }>;
}) {
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
return (
<main className="shell">
<Hero back />
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
<p className="mt-3 text-[15px] font-bold"> </p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
,
100 .
</p>
</section>
<Hero back backHistory />
<p className="mt-6 text-center text-[13px] leading-relaxed text-[var(--ink-muted)]">
{t.goldDesc}
</p>
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
<Leaderboard lang={lang} defaultOpen />
<Footer />
</main>
);

View File

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
@ -63,7 +64,7 @@ export default async function MatchPage({
text:
lang === "en"
? `${hook} — see all 3 AI picks and make yours!`
: `${hook} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
: `${hook} — AI 셋의 예측을 보고 당신의 픽을 찍어보세요!`,
}}
/>
@ -82,6 +83,17 @@ export default async function MatchPage({
shareUrl={matchUrl(match.matchId)}
lang={lang}
/>
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 사용해보기 버튼과 동일 사양(퍼플 #A65EFF) */}
<Link
href={lang === "en" ? "/?lang=en" : "/"}
className="mt-7 flex items-center justify-center gap-1.5 rounded-full bg-[#A65EFF] px-6 py-3 text-[15px] font-extrabold text-white transition active:scale-[0.99]"
style={{ boxShadow: "0 10px 28px rgba(166,94,255,0.40)" }}
>
{t.moreMatches}
<span aria-hidden></span>
</Link>
<Footer lang={lang} />
</main>
);

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
import { DEMO_FORCE_OPEN, MODEL_VERSIONS } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
import { type Lang, dict, teamShort } from "@/lib/i18n";
import type {
@ -14,8 +14,16 @@ import type {
} from "@/lib/types";
import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag";
import Leaderboard from "./Leaderboard";
import {
fetchMyPredictions,
saveMyPrediction,
rememberEmail,
recallEmail,
type MyPrediction,
} from "@/lib/myPredictions";
type Step = "pick" | "form" | "done";
type Step = "form" | "done";
const MODEL_ICON: Record<ModelName, string> = {
GPT: "/icons/gpt.png",
@ -25,6 +33,12 @@ const MODEL_ICON: Record<ModelName, string> = {
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
function fmtSubmitted(iso: string): string {
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
return m ? `${+m[1]}.${+m[2]}` : "";
}
export default function Arena({
match,
predictions,
@ -41,14 +55,44 @@ export default function Arena({
const t = dict(lang);
const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang);
const genDate = (predictions[0]?.generatedAt ?? "").replace(/-/g, ".");
const modelVersions = predictions.map((p) => MODEL_VERSIONS[p.model]).join(" · ");
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [scoreA, setScoreA] = useState(2);
const [scoreB, setScoreB] = useState(1);
const [step, setStep] = useState<Step>("pick");
const [step, setStep] = useState<Step>("form");
const [email, setEmail] = useState("");
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
const [votesExpanded, setVotesExpanded] = useState(false);
// 재방문 시: 기억된 이메일 복원 → 내 지난 예측 로드 (로그인 대체)
useEffect(() => {
const saved = recallEmail();
if (!saved) return;
setEmail(saved);
fetchMyPredictions(saved).then(setMyPreds);
}, []);
// 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 강조)
const myThisMatch = useMemo(
() => myPreds.find((p) => p.matchId === match.matchId),
[myPreds, match.matchId],
);
useEffect(() => {
if (myThisMatch) {
setScoreA(myThisMatch.scoreA);
setScoreB(myThisMatch.scoreB);
}
}, [myThisMatch]);
// 이메일 입력이 유효해지면 그 즉시 내 기록 조회(다른 기기/세션 흔적 표시)
const onEmailChange = (v: string) => {
setEmail(v);
if (EMAIL_RE.test(v.trim())) fetchMyPredictions(v).then(setMyPreds);
};
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
useEffect(() => {
@ -78,19 +122,32 @@ export default function Arena({
.map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB }));
}, [outcome, scoreA, scoreB, predictions]);
const submitPick = () => {
if (!outcome) return;
setStep("form");
};
const confirmSubmit = () => {
if (!EMAIL_RE.test(email.trim())) return;
setCrowd((c) => ({
...c,
total: c.total + 1,
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
}));
const emailValid = EMAIL_RE.test(email.trim());
const confirmSubmit = async () => {
if (!emailValid || !outcome) return;
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
if (!myThisMatch) {
setCrowd((c) => ({
...c,
total: c.total + 1,
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
}));
}
// 내 예측 저장(경기당 1건 upsert) + 이메일 기억 → 재방문 자동 복원
await saveMyPrediction(email, {
matchId: match.matchId,
teamAShort: aShort,
teamBShort: bShort,
kickoffKst: match.kickoffKst,
outcome,
scoreA,
scoreB,
submittedAt: new Date().toISOString(),
});
rememberEmail(email);
setMyPreds(await fetchMyPredictions(email));
setStep("done");
};
@ -104,8 +161,8 @@ export default function Arena({
: `I picked ${me} — different from all 3 AIs! You? — TriplePick`;
}
return sameAI
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
? `나는 ${me}. ${sameAI}와 같은 선택! 당신은? — TriplePick`
: `나는 ${me}. AI 셋과 다 다른 선택! 당신은? — TriplePick`;
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
const copyLink = async () => {
@ -142,8 +199,14 @@ export default function Arena({
{/* ===== 레이어드 화이트 시트 (002) ===== */}
<section className="sheet mt-4 p-5 text-[var(--ink)]">
<div className="mb-3">
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
<div className="mb-3 flex items-end justify-between gap-2">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.aiBattle}</h2>
<div className="flex-1 pb-1 text-center text-[11px] leading-snug">
<div className="font-bold text-[var(--ink)]">
{t.genLabel} · {genDate} 00:00 KST
</div>
<div className="text-[var(--ink-muted)]">{modelVersions}</div>
</div>
</div>
<div className="flex flex-col gap-3">
@ -255,43 +318,45 @@ export default function Arena({
</section>
)}
{/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */}
{!finished && step === "pick" && (
{/* ===== 투표 마감/오픈 전: 상태 버튼만 ===== */}
{!finished && disabled && (
<button
onClick={submitPick}
disabled={!outcome || disabled}
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
disabled
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold opacity-60"
>
{ctaLabel}
</button>
)}
{/* ===== 폼: 이메일만 (D5, 닉네임 제거) ===== */}
{!finished && step === "form" && (
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
{/* ===== 투표 제출 (D5): 이메일 + 단일 CTA를 한 카드로 통합 ===== */}
{!finished && !disabled && step === "form" && (
<div className="mt-5 rounded-2xl border-2 border-[var(--green)]/60 bg-[var(--bg2)] p-4 shadow-[0_0_24px_rgba(74,255,160,0.08)]">
<div className="mb-2.5 text-center text-[15px] font-extrabold text-[var(--green)]">
{t.emailGate}
</div>
<input
type="email"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => onEmailChange(e.target.value)}
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-xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]"
/>
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
<label className="mt-2.5 flex items-start gap-2 text-[13px] leading-snug text-[var(--ink-muted)]">
<input
type="checkbox"
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
className="mt-0.5 accent-[var(--mint)]"
className="mt-0.5 accent-[var(--share)]"
/>
{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"
disabled={!emailValid}
className="btn-share mt-3.5 flex w-full items-center justify-center gap-1.5 rounded-xl py-3 text-[15px] font-extrabold transition active:scale-[0.99] disabled:opacity-[0.72]"
>
{t.submit}
{t.submit} <span aria-hidden></span>
</button>
</div>
)}
@ -325,6 +390,78 @@ export default function Arena({
</div>
)}
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
{myPreds.length > 0 && (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<div className="mb-3 flex items-baseline justify-between gap-2">
<h3 className="text-[16px] font-extrabold">{t.myVotesTitle}</h3>
<span className="shrink-0 text-[12px] text-[var(--ink-muted)]">
{t.myVotesSub}
</span>
</div>
<ul className="flex flex-col gap-2">
{(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => {
const isThis = p.matchId === match.matchId;
const pickTxt =
p.outcome === "TEAM_A_WIN"
? `${p.teamAShort} ${t.win}`
: p.outcome === "TEAM_B_WIN"
? `${p.teamBShort} ${t.win}`
: t.drawLabel;
return (
<li
key={p.matchId}
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
isThis
? "border-[var(--share)] bg-[rgba(166,94,255,0.1)]"
: "border-[var(--line-d)]"
}`}
>
<div className="min-w-0">
<div className="text-[15px] font-bold text-white">
{p.teamAShort} {p.scoreA}-{p.scoreB} {p.teamBShort}
</div>
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">
{pickTxt}
{isThis && (
<span className="text-[var(--share)]"> · {t.thisMatch}</span>
)}
</div>
</div>
{p.result ? (
<span
className={`shrink-0 text-[13px] font-bold ${
p.result.hitOutcome
? "text-[var(--share)]"
: "text-[var(--ink-muted)]"
}`}
>
{p.result.hitOutcome ? t.hit : t.miss}
</span>
) : (
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">
{fmtSubmitted(p.submittedAt)}
</span>
)}
</li>
);
})}
</ul>
{myPreds.length > 1 && (
<button
onClick={() => setVotesExpanded((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
>
{votesExpanded ? t.showLess : t.showMore(myPreds.length - 1)}
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
</button>
)}
</section>
)}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
<Leaderboard lang={lang} />
{/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">

27
components/BackButton.tsx Normal file
View File

@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
// 뒤로가기 — 직전 페이지(예: 투표하던 매치 페이지)로 복귀.
// 히스토리가 없으면(직접 진입) 홈으로 폴백.
export default function BackButton({
label,
home,
}: {
label: string;
home: string;
}) {
const router = useRouter();
const goBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) router.back();
else router.push(home);
};
return (
<button
onClick={goBack}
className="flex items-center gap-1.5 rounded-full border border-white/12 bg-white/8 px-[18px] py-[9px] text-[18px] font-bold text-white/85 active:scale-95"
>
{label}
</button>
);
}

View File

@ -17,7 +17,7 @@ export default function Footer({ lang = "ko" }: { lang?: Lang }) {
</div>
<div className="mt-3 text-[10px] text-white/70">
© 2026 TriplePick · AIO2O · @triplepick_ai
© 2026 TriplePick · AIO2O · @triplepickai
</div>
</footer>
);

View File

@ -1,6 +1,7 @@
import Link from "next/link";
import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch";
import BackButton from "./BackButton";
import { type Lang, dict } from "@/lib/i18n";
type Share = { url: string; title: string; text: string };
@ -8,10 +9,12 @@ type Share = { url: string; title: string; text: string };
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
export default function Hero({
back = false,
backHistory = false,
share,
lang = "ko",
}: {
back?: boolean;
backHistory?: boolean; // true면 홈 대신 직전 페이지로 복귀(뒤로가기)
share?: Share;
lang?: Lang;
}) {
@ -21,12 +24,16 @@ export default function Hero({
<header className="pt-5 text-center">
{back && (
<div className="mb-3 flex items-center justify-between">
<Link
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>
{backHistory ? (
<BackButton label={t.backPrev} home={home} />
) : (
<Link
href={home}
className="flex items-center gap-1.5 rounded-full border border-white/12 bg-white/8 px-[18px] py-[9px] text-[18px] font-bold text-white/85 active:scale-95"
>
{t.back}
</Link>
)}
{share && (
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
)}

117
components/Leaderboard.tsx Normal file
View File

@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import { type Lang, dict } from "@/lib/i18n";
import {
fetchLeaderboard,
type LeaderEntry,
type LeaderTab,
} from "@/lib/leaderboard";
// 누적 랭킹 — 참가자 / AI 모델 2탭, 폴딩(기본 접힘)
// 매치 페이지(골드 카드 직후)에 삽입. defaultOpen=true 면 전체 페이지용 펼친 상태.
export default function Leaderboard({
lang = "ko",
defaultOpen = false,
}: {
lang?: Lang;
defaultOpen?: boolean;
}) {
const t = dict(lang);
const [open, setOpen] = useState(defaultOpen);
const [tab, setTab] = useState<LeaderTab>("voters");
const [rows, setRows] = useState<LeaderEntry[]>([]);
// 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
useEffect(() => {
if (!open) return;
let alive = true;
fetchLeaderboard(tab).then((r) => alive && setRows(r));
return () => {
alive = false;
};
}, [open, tab]);
return (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<h3 className="text-[19px] font-extrabold">{t.lbTitle}</h3>
{open && (
<>
{/* 탭 */}
<div className="mt-3 grid grid-cols-2 gap-2">
{(
[
["voters", t.lbTabVoters],
["ai", t.lbTabAI],
] as [LeaderTab, string][]
).map(([val, label]) => (
<button
key={val}
onClick={() => setTab(val)}
className={`rounded-xl py-2.5 text-[15px] font-extrabold transition ${
tab === val
? "bg-[var(--share)] text-white"
: "border border-[var(--line-d)] text-[var(--ink-muted)]"
}`}
>
{label}
</button>
))}
</div>
{/* 헤더 라벨 */}
<div className="mt-3 flex items-center justify-between px-3 text-[12px] font-bold text-[var(--ink-muted)]">
<span>{tab === "ai" ? t.lbTabAI : t.lbTabVoters}</span>
<span>{t.lbPoints}</span>
</div>
{/* TOP 10 목록 */}
<ol className="mt-1.5 flex flex-col gap-1.5">
{rows.map((r, i) => {
const top3 = i < 3;
return (
<li
key={`${r.label}-${i}`}
className={`flex items-center gap-3 rounded-xl border px-3 py-2.5 ${
top3
? "border-[var(--share)]/50 bg-[rgba(166,94,255,0.08)]"
: "border-[var(--line-d)]"
}`}
>
<span
className={`w-6 shrink-0 text-center font-mono text-[16px] font-extrabold tabular-nums ${
top3 ? "text-[var(--share)]" : "text-[var(--ink-muted)]"
}`}
>
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-[16px] font-bold text-white">
{r.label}
</span>
<span className="shrink-0 font-mono text-[16px] font-extrabold tabular-nums text-white">
{r.points.toLocaleString()}
<span className="ml-0.5 text-[12px] text-[var(--ink-muted)]">
{t.lbUnit}
</span>
</span>
</li>
);
})}
</ol>
</>
)}
{/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
{!defaultOpen && (
<button
onClick={() => setOpen((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-xl border border-[var(--share)] bg-[rgba(166,94,255,0.12)] py-2.5 text-[15px] font-extrabold text-[var(--share)] transition active:scale-[0.99]"
>
{open ? t.lbClose : t.lbOpen}
<span aria-hidden>{open ? "▲" : "▼"}</span>
</button>
)}
</section>
);
}

View File

@ -63,6 +63,16 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
if (tally.d) split.push(`${t.draw} ${tally.d}`);
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
// 종료 경기: 결과 스코어 + 승자 라벨 (예: "멕시코 승")
const result = match.result;
const winLabel = result
? result.outcome === "TEAM_A_WIN"
? `${teamShort(match.teamA, lang)} ${t.win}`
: result.outcome === "TEAM_B_WIN"
? `${teamShort(match.teamB, lang)} ${t.win}`
: t.drawLabel
: null;
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
return (
@ -74,6 +84,9 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<span className="font-mono font-bold text-white/70">
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
{tRound(match.roundLabel, lang)}
{winLabel && (
<span className="ml-1 font-semibold text-[#94FBE0]">· {winLabel}</span>
)}
</span>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]}
@ -85,7 +98,15 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div>
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
{result ? (
<span className="flex items-center gap-1.5 font-impact text-[18px] italic">
<span className="tabular-nums text-white">{result.scoreA}</span>
<span className="text-[13px] text-[#94FBE0]">VS</span>
<span className="tabular-nums text-white">{result.scoreB}</span>
</span>
) : (
<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">{teamShort(match.teamB, lang)}</span>
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />

View File

@ -39,7 +39,7 @@ export default function ShareButton({
return (
<button
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-[18px] py-[9px] text-[18px] font-bold text-[var(--share)] transition active:scale-95"
>
{copied ? copiedLabel : label}
</button>

View File

@ -1,7 +1,14 @@
import type { Team } from "@/lib/types";
// 모든 경기에 일반화된 국기 렌더.
// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
// 모든 국기를 직사각형 SVG로 통일 (flagcdn, 퍼블릭 도메인).
// 이모지(OS별 휘날림/광택 렌더) 제거 → 일관된 사각형. object-cover로 박스를 꽉 채움(왜곡 없음).
const FLAG_FILE: Record<string, string> = {
KOR: "kr",
CZE: "cz",
MEX: "mx",
RSA: "za",
};
export default function TeamFlag({
team,
className = "",
@ -9,30 +16,17 @@ export default function TeamFlag({
team: Team;
className?: string;
}) {
if (team.code === "KOR") {
const file = FLAG_FILE[team.code];
if (file) {
return (
<img
src="/icons/kor.png"
src={`/icons/flags/${file}.svg`}
alt={team.name}
className={`flag-img object-cover ${className}`}
className={`border border-[var(--line-d)] object-cover ${className}`}
/>
);
}
if (team.code === "CZE") {
return (
<svg
viewBox="0 0 6 4"
className={`border border-[var(--line-d)] ${className}`}
preserveAspectRatio="xMidYMid meet"
aria-label={team.name}
>
<rect width="6" height="2" y="0" fill="#ffffff" />
<rect width="6" height="2" y="2" fill="#d7141a" />
<polygon points="0,0 3,2 0,4" fill="#11457e" />
</svg>
);
}
// 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
// 미등록 국가 fallback — 이모지(직사각형 박스, 잘림 없음)
return (
<span
className={`grid place-items-center overflow-hidden border border-[var(--line-d)] bg-white/[0.06] ${className}`}

56
docs/AI-PREDICTIONS.md Normal file
View File

@ -0,0 +1,56 @@
# AI 예측 생성 — 3모델 (GPT · Claude · Gemini)
최종 갱신: 2026-06-10 KST
관련: `lib/mockData.ts`(FEATURED_PREDICTIONS), `docs/BACKEND.md`(자동화), `.env.example`
> 매일 KST 0시, 남은 경기를 GPT·Claude·Gemini 3개 모델로 예측 생성(immutable). 현재는 수동 주입, 추후 Cloud Functions 자동화.
---
## 1. 모델 ID (실측 확정 · 2026-06-10)
각 API의 `GET /models` 엔드포인트로 실제 사용 가능한 최신 ID를 확인해 고정. (추측 금지)
| 모델 | 최신 ID | 호출 엔드포인트 |
|---|---|---|
| **GPT** | `gpt-5.5` | `POST https://api.openai.com/v1/chat/completions` |
| **Claude** | `claude-opus-4-8` | `POST https://api.anthropic.com/v1/messages` (`anthropic-version: 2023-06-01`) |
| **Gemini** | `gemini-3.5-flash` | `POST https://generativelanguage.googleapis.com/v1beta/models/{id}:generateContent?key=` |
> 키는 `004. Dev/.env`(저장소 밖, 시크릿). 저장소엔 `.env.example`의 빈 슬롯만.
## 2. ⚠️ 함정 / 운영 노트
- **Gemini는 `generationConfig`(responseMimeType·thinkingConfig) 주면 빈 응답** 반환. → **평문 본문(contents만)으로 호출하고 응답 text에서 JSON 정규식 파싱**할 것. thinking 모델이라 추론 토큰을 먼저 쓴다.
- **Anthropic 자동 호출엔 크레딧 필요**: 현재 키는 `credit balance too low`. console.anthropic.com 결제 전까지 Claude는 수동 주입.
- **Python urllib는 macOS에서 SSL 인증서 실패** 가능 → `curl`(시스템 인증서) 사용 권장.
- 출력은 JSON 스키마로 강제: `{winner, score_korea, score_czechia, confidence, reason}`.
## 3. 체코전(한국 vs 체코) — 실제 출력 (2026-06-10)
`lib/mockData.ts``FEATURED_PREDICTIONS`에 주입됨.
| 모델 | 결과 | 스코어(한:체) | 확신 | 근거(KO) |
|---|---|---|---|---|
| GPT (gpt-5.5) | 무승부 | 1 : 1 | 54% | 한국 역습과 체코 제공권이 팽팽 |
| Claude (claude-opus-4-8) | 한국 승 | 2 : 1 | 57% | 손흥민·이강인 측면 창의성이 체코 블록을 연다 |
| Gemini (gemini-3.5-flash) | 한국 승 | 2 : 1 | 55% | 이강인·손흥민 공격력, 체코 수비에 근소 우세 |
→ "AI가 갈렸다": GPT 무승부 vs Gemini·Claude 한국 2-1.
## 4. 호출 프롬프트 (참고)
```
2026년 6월 한국(Korea Republic) vs 체코(Czechia) 축구 경기를 최근 전력 기반으로 예측해줘.
다른 설명 없이 이 JSON 한 줄만:
{"winner":"한국|무승부|체코","score_korea":정수,"score_czechia":정수,"confidence":0-100,"reason":"한국어 한 줄"}
```
- winner는 teamA(한국)=score_korea, teamB(체코)=score_czechia 기준으로 매핑.
- 랜딩 타입 변환: 한국 승=`TEAM_A_WIN` / 무승부=`DRAW` / 체코 승=`TEAM_B_WIN`.
## 5. 다음 단계
- [ ] 나머지 Group A 5경기도 동일 호출로 주입
- [ ] Cloud Functions로 매일 KST 0시 자동 생성 → Firestore 저장(immutable), 탈락팀 제외 (`docs/BACKEND.md`)
- [ ] Anthropic 크레딧 충전 후 Claude 자동 호출 포함

View File

@ -86,8 +86,25 @@
## 7. Do / Don't
- ✅ 실제 모델 아이콘 · 클래식 축구공(투명) · 라이트닝 그린 VS · 화이트 시트 + 다크 하이브리드 · 민트 CTA
- ✅ 3색(틸그린/오렌지/블루)으로 모델 구분, 글로우는 그린
- ❌ "월드컵" 표현 / 골드 톤 / 단색 초록 일변도 / 레터박스 가짜 아이콘 / 이모지 축구공 / 임의 다크 단색(=v1 회귀)
- ❌ "월드컵" 표현 / 골드 톤 / 단색 초록 일변도 / 레터박스 가짜 아이콘 / 임의 다크 단색(=v1 회귀)
- ❌ 한글을 이미지로 굽기(가독성↓) — 텍스트 레이어로
- ❌ **모든 이모지 절대 금지** (👆 손가락·🏆·⚽·🤖·🟠 등 픽토그램·컬러 이모지). 강조·지시는 **컬러·타이포·실제 SVG/PNG 아이콘**으로 표현. (typographic 글리프 `→ ★ ✓ ·` 는 허용 — 이건 이모지가 아님). 디자인·영상·랜딩 전부 동일 적용. (2026-06-10 확정)
- ❌ **글로우(glow) 이펙트 금지** (네온 textShadow·box-shadow 발광·펄스 글로우). 가독용 **어두운 드롭섀도/스크림은 허용**(발광 아님). 강조는 컬러·크기·굵기·보더로. (2026-06-11 확정)
- ❌ **em dash(—) 사용 금지** (카피·자막 전부). 대신 느낌표·쉼표·줄바꿈·중점(·)으로. (리더보드 '미기록' 표기는 하이픈 `-`). (2026-06-11 확정)
- ⚠️ **브랜드 그린 = `#4AFFA0` 정확값** 사용. 영상 mp4 인코딩 시 yuv420p 채도 저하로 탁해 보이면 인코딩에서 채도 보정(`eq=saturation`)으로 살릴 것.
---
## 10. SNS 콘텐츠 템플릿 (SSOT: `Worldcup 2026/006. SNS/TriplePick_Social Content.pptx`)
> 사용자가 만든 PPTX = BG·BI·자막위치·CI·ending·Thumbnail 공식 템플릿. 원본 미디어는 PPTX에서 추출(`003. video/assets/sns-template/`).
- **BG**: 다크 네이비/차콜 + 사선 그린→블루 그라디언트 (원본 `image1.png`, 어둡게 깔아 사용).
- **BI** (TriplePick 프로필 hex 배지 + "TRIPLE PICK 2026" 워드마크): **좌상단 고정**, 가로 락업. 로고 벡터 = `image2.svg`.
- **CI** (AIO2O): **하단 중앙 고정**. 원본 `image3.png`(고해상).
- **자막 위치**: 매치업 배너(상단·국기) → 훅 질문 중앙(예 "어떤 AI 모델이 맞췄을까?") → "AI 셋 vs 당신 / 누가 맞히나?" 하단 중앙.
- **Thumbnail**: **동물 매치업**(베이스 `image4.png`). 규칙 — **국가는 그 나라를 상징하는 힘센 동물로, 한국은 호랑이 고정**, 상대국은 상징 동물(체코=사자). 사선 분할(왼쪽위→오른쪽아래), 레드(한국)/블루(상대). 한글 텍스트는 이미지에 굽지 말고 Remotion 오버레이.
- **Ending**: TriplePick 로고 + 워드마크 + AIO2O CI, **중앙 정렬**. 글로우 없음.
- **활용 파이프라인**: PPTX 미디어를 추출해 직접 사용(BG=image1, 로고=image2.svg, CI=image3, 썸네일 베이스=image4) → Remotion에서 한글 텍스트만 오버레이. 새 경기는 썸네일 베이스 동물만 교체(한국=호랑이 고정).
---

View File

@ -40,6 +40,7 @@ export function roundLabel(label: string, lang: Lang): string {
type Dict = {
heroPill: string;
back: string;
backPrev: string;
share: string;
shareCopied: string;
introLead: string;
@ -62,6 +63,7 @@ type Dict = {
ctaResult: string;
ctaLocked: string;
ctaOpens: (d: string) => string;
emailGate: string;
emailPh: string;
notify: string;
submit: string;
@ -91,12 +93,28 @@ type Dict = {
ado2Tagline: string;
ado2Sub: string;
ado2Cta: string;
genLabel: string;
moreMatches: string;
myVotesTitle: string;
myVotesSub: string;
thisMatch: string;
showMore: (n: number) => string;
showLess: string;
lbTitle: string;
lbTabVoters: string;
lbTabAI: string;
lbPoints: string;
lbUnit: string;
lbOpen: string;
lbClose: string;
lbAll: string;
};
export const DICT: Record<Lang, Dict> = {
ko: {
heroPill: "AI와 함께하는 2026 월드컵 승부예측 챌린지",
heroPill: "AI와 함께하는 2026 글로벌 축구 축전 승부예측 챌린지",
back: "← 전체 일정",
backPrev: "← 투표로 돌아가기",
share: "투표 공유하기",
shareCopied: "링크 복사됨 ✓",
introLead: "GPT · Claude · Gemini가 매 경기를\n서로 다르게 예측합니다.",
@ -104,7 +122,7 @@ export const DICT: Record<Lang, Dict> = {
prizeLine2: "최종 우승자에게 100만원 상금",
schedTitle: "전체 일정",
schedGuide: "투표하고 싶은 경기를 선택해주세요",
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "종료" },
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "투표종료" },
aiPicks: "AI 픽",
draw: "무",
joined: (n) => `${n}명 참여`,
@ -119,9 +137,10 @@ export const DICT: Record<Lang, Dict> = {
ctaResult: "결과 보기",
ctaLocked: "예측 마감 (경기 시작)",
ctaOpens: (d) => `투표 오픈 ${d}`,
emailPh: "이메일 (결과·다음 경기 알림)",
notify: "경기 결과가 나오면 알려주세요. 다음 경기·100만원 챌린지 소식도 받겠습니다.",
submit: "제출하고 AI와 겨루기",
emailGate: "투표를 제출하려면 이메일을 입력하세요",
emailPh: "이메일 입력 → 투표 결과·다음 경기 알림",
notify: "결과 알림 + 다음 경기 소식 + ₩100만 챌린지 기회까지 무료로 받기",
submit: "투표 제출하고 AI와 겨루기",
myPick: "내 예측",
sameAI: (m, exact) =>
`당신은 ${m}와 같은 선택입니다.${exact ? " 스코어까지 일치!" : ""} 나머지 AI는 생각이 다릅니다.`,
@ -141,19 +160,35 @@ export const DICT: Record<Lang, Dict> = {
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 예측 게임입니다 (제휴·후원·운영 아님).",
"Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or any official football tournament.",
footerDisc1: "FIFA 및 공식 축구 대회와 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).",
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
genLabel: "예측 기준",
moreMatches: "다른 경기 투표하기",
myVotesTitle: "내 지난 예측",
myVotesSub: "이메일 기준 · 최신순",
thisMatch: "이번 경기",
showMore: (n) => `더보기 +${n}`,
showLess: "접기",
lbTitle: "누적 랭킹 TOP 10",
lbTabVoters: "참가자",
lbTabAI: "AI 모델",
lbPoints: "누적 포인트",
lbUnit: "P",
lbOpen: "랭킹 보기",
lbClose: "접기",
lbAll: "전체 보기",
adLabel: "광고",
ado2Tagline: "AI 마케팅 자동화 플랫폼",
ado2Sub: "AI Marketing Automation Platform →",
ado2Cta: "사용해보기",
},
en: {
heroPill: "AI-powered World Cup 2026 match predictions",
heroPill: "AI-powered Global Football Festival 2026 match predictions",
back: "← All matches",
backPrev: "← Back to voting",
share: "Share vote",
shareCopied: "Link copied ✓",
introLead: "GPT · Claude · Gemini predict\nevery match differently.",
@ -161,7 +196,7 @@ export const DICT: Record<Lang, Dict> = {
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" },
phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", finished: "Voting closed" },
aiPicks: "AI picks",
draw: "Draw",
joined: (n) => `${n} joined`,
@ -176,9 +211,10 @@ export const DICT: Record<Lang, Dict> = {
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",
emailGate: "Enter your email to submit your vote",
emailPh: "Your email → result & next-match alerts",
notify: "Result alerts + next-match news + a shot at the ₩1,000,000 challenge — free",
submit: "Submit your vote & face the AI",
myPick: "My Pick",
sameAI: (m, exact) =>
`You matched ${m}.${exact ? " Exact score too!" : ""} The other AIs disagree.`,
@ -198,11 +234,26 @@ export const DICT: Record<Lang, Dict> = {
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.",
"Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or any official football tournament.",
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}`,
genLabel: "As of",
moreMatches: "Vote on another match",
myVotesTitle: "Your past predictions",
myVotesSub: "By email · newest first",
thisMatch: "This match",
showMore: (n) => `Show ${n} more`,
showLess: "Show less",
lbTitle: "Cumulative Ranking · TOP 10",
lbTabVoters: "Players",
lbTabAI: "AI models",
lbPoints: "Points",
lbUnit: "P",
lbOpen: "View ranking",
lbClose: "Collapse",
lbAll: "View all",
adLabel: "AD",
ado2Tagline: "AI Marketing Automation Platform",
ado2Sub: "Visit ado2.o2osolution.ai →",

56
lib/leaderboard.ts Normal file
View File

@ -0,0 +1,56 @@
// 누적 랭킹 — 투표 결과가 반영된 포인트 축적 TOP 10
// ─────────────────────────────────────────────────────────────
// UI(Leaderboard.tsx)는 fetchLeaderboard 의 "시그니처(입출력 타입)"에만 의존한다.
// 백엔드 개발자는 fetchLeaderboard 의 *본문만* 콜러블 함수(getLeaderboard)
// 호출로 교체하면 UI 수정 불필요.
// const { fns } = getFirebase();
// const res = await httpsCallable(fns, "getLeaderboard")({ tab });
// return res.data as LeaderEntry[]; // 누적 포인트 내림차순, 최대 10
//
// 포인트 산정(서버 책임): 종료 경기마다 예측 정확도로 가점 → 누적.
// PII: 참가자 label 은 *마스킹된 이메일 아이디*만 반환할 것(전체 이메일 금지).
export type LeaderTab = "voters" | "ai";
/** 랭킹 1행 — 표시용 2개 항목(이름/아이디 + 누적 포인트) */
export type LeaderEntry = {
label: string; // 참가자: 마스킹된 이메일 아이디 / AI: 모델명(GPT·Claude·Gemini)
points: number; // 누적 포인트
};
/** 공개 노출용 이메일 아이디 마스킹: "haewonkam@x.com" → "hae****m" */
export function maskEmailId(email: string): string {
const id = (email.split("@")[0] || email).trim();
if (id.length <= 2) return id[0] + "*";
if (id.length <= 4) return id.slice(0, 2) + "*".repeat(id.length - 2);
return id.slice(0, 3) + "*".repeat(Math.min(4, id.length - 4)) + id.slice(-1);
}
// ── 데모 목업 (결정론적) — 운영에서는 getLeaderboard 콜러블로 대체 ──
const DEMO_VOTERS: LeaderEntry[] = [
{ label: "cha***n", points: 1840 },
{ label: "jun***0", points: 1715 },
{ label: "min***k", points: 1660 },
{ label: "soo***2", points: 1590 },
{ label: "hae***i", points: 1505 },
{ label: "yul***7", points: 1430 },
{ label: "dae***n", points: 1388 },
{ label: "seo***3", points: 1322 },
{ label: "par***k", points: 1260 },
{ label: "won***9", points: 1195 },
];
const DEMO_AI: LeaderEntry[] = [
{ label: "Gemini", points: 1420 },
{ label: "GPT", points: 1305 },
{ label: "Claude", points: 1180 },
];
/* ============================================================
* TOP 10
* : getLeaderboard .
* ============================================================ */
export async function fetchLeaderboard(tab: LeaderTab): Promise<LeaderEntry[]> {
const list = tab === "ai" ? DEMO_AI : DEMO_VOTERS;
return [...list].sort((a, b) => b.points - a.points).slice(0, 10);
}

View File

@ -8,16 +8,17 @@ import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./type
import { FEATURED, GROUP_A } from "./schedule";
import { type Lang, teamShort } from "./i18n";
// 체코전 — 실제 3모델 API 출력 (2026-06-10 생성: gpt-5.5 / claude-opus-4-8 / gemini-3.5-flash)
// 체코전 — 실제 3모델 API 출력 (2026-06-10 캐노니컬 스냅샷: gpt-5.5 / claude-opus-4-8 / gemini-2.5-flash)
// 결과: GPT 무 1-1(conf45) / Claude 무 1-1(conf55) / Gemini 한국 2-1(conf60) — 둘은 무승부, Gemini만 한국 승리
const FEATURED_REASONS: Record<Lang, Record<ModelName, string>> = {
ko: {
GPT: "한국 역습과 체코 제공권이 팽팽",
Claude: "손흥민·이강인 측면 창의성이 체코 블록을 연다",
GPT: "전력과 압박 강도 모두 팽팽",
Claude: "양 팀 전력 비슷, 박빙 무승부",
Gemini: "이강인·손흥민 공격력, 체코 수비에 근소 우세",
},
en: {
GPT: "Korea's counters vs Czech aerial — dead even",
Claude: "Son & Lee's wing play cracks the Czech block",
GPT: "Power and pressing — dead even",
Claude: "Evenly matched, a tight draw likely",
Gemini: "Korea's attack edges a stubborn Czech defense",
},
};
@ -32,31 +33,31 @@ export const DEMO_FORCE_OPEN = true;
const FEATURED_PREDICTIONS: AIPrediction[] = [
{
matchId: MATCH.matchId,
model: "GPT", // gpt-5.5: 무승부 1-1
model: "GPT", // gpt-5.5: 무승부 1-1 (conf 45)
outcome: "DRAW",
scoreA: 1,
scoreB: 1,
confidencePct: 54,
confidencePct: 45,
reasonShort: FEATURED_REASONS.ko.GPT,
generatedAt: "2026-06-10",
},
{
matchId: MATCH.matchId,
model: "Claude", // claude-opus-4-8: 한국 승 2-1
outcome: "TEAM_A_WIN",
scoreA: 2,
model: "Claude", // claude-opus-4-8: 무승부 1-1 (conf 55)
outcome: "DRAW",
scoreA: 1,
scoreB: 1,
confidencePct: 57,
confidencePct: 55,
reasonShort: FEATURED_REASONS.ko.Claude,
generatedAt: "2026-06-10",
},
{
matchId: MATCH.matchId,
model: "Gemini", // gemini-3.5-flash: 한국 승 2-1
model: "Gemini", // gemini-2.5-flash: 한국 승 2-1 (conf 60)
outcome: "TEAM_A_WIN",
scoreA: 2,
scoreB: 1,
confidencePct: 55,
confidencePct: 60,
reasonShort: FEATURED_REASONS.ko.Gemini,
generatedAt: "2026-06-10",
},
@ -74,6 +75,13 @@ function hash(s: string): number {
const MODELS: ModelName[] = ["GPT", "Claude", "Gemini"];
// 예측에 사용한 실제 모델 버전 (UI 투명성 표기용). 갱신 시 .env·AI-PREDICTIONS.md와 함께 변경.
export const MODEL_VERSIONS: Record<ModelName, string> = {
GPT: "gpt-5.5",
Claude: "claude-opus-4-8",
Gemini: "gemini-2.5-flash",
};
// AI 셋이 갈리도록 만든 결과 조합 (홈/원정 균형)
const OUTCOME_SETS: Outcome[][] = [
["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"],
@ -155,6 +163,50 @@ function generatePredictions(match: Match, lang: Lang): AIPrediction[] {
});
}
// 나머지 5경기 — 실제 3모델 API 출력 (2026-06-10, gpt-5.5 / gemini-3.5-flash / claude-opus-4-8 직접)
// scoreA=teamA(앞 팀), scoreB=teamB(뒤 팀). outcome: TEAM_A_WIN / DRAW / TEAM_B_WIN.
type Curated = {
model: ModelName;
outcome: Outcome;
scoreA: number;
scoreB: number;
confidencePct: number;
ko: string;
en: string;
};
const CURATED: Record<string, Curated[]> = {
// 개막전 — 멕시코 vs 남아공
A_MEX_RSA_20260612: [
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 62, ko: "멕시코 전력 우위, 남아공 수비 불안", en: "Mexico's edge vs a shaky South Africa defense" },
{ model: "Claude", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 0, confidencePct: 64, ko: "개최국 멕시코의 홈 압박과 측면 우위", en: "Host Mexico's home press and wing edge" },
{ model: "Gemini", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 0, confidencePct: 75, ko: "멕시코의 홈 이점과 객관적 전력 우세", en: "Mexico's home advantage and clear quality" },
],
// 체코 vs 남아공
A_CZE_RSA_20260619: [
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 1, scoreB: 0, confidencePct: 56, ko: "체코의 조직력·결정력이 근소 우위", en: "Czechia's structure & finishing just edge it" },
{ model: "Claude", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 0, confidencePct: 60, ko: "체코의 세트피스와 피지컬 우위", en: "Czechia's set pieces and physicality" },
{ model: "Gemini", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 65, ko: "체코의 전력 우세와 골 결정력 차이", en: "Czechia's quality and finishing decide it" },
],
// 멕시코 vs 한국 — 모델 갈림
A_MEX_KOR_20260619: [
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 55, ko: "홈 압박으로 멕시코가 한 골 차 우세", en: "Home press gives Mexico a one-goal edge" },
{ model: "Claude", outcome: "DRAW", scoreA: 1, scoreB: 1, confidencePct: 50, ko: "홈의 멕시코, 역습의 한국 — 균형", en: "Mexico's press vs Korea's counters — even" },
{ model: "Gemini", outcome: "DRAW", scoreA: 1, scoreB: 1, confidencePct: 65, ko: "홈 멕시코와 한국 유럽파의 접전 무승부", en: "Home Mexico vs Korea's Europe stars — a draw" },
],
// 체코 vs 멕시코
A_CZE_MEX_20260625: [
{ model: "GPT", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 58, ko: "멕시코의 홈 이점과 공격력 우세", en: "Mexico's home edge and attack prevail" },
{ model: "Claude", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 57, ko: "멕시코의 템포와 개인기가 체코를 넘는다", en: "Mexico's tempo and flair edge Czechia" },
{ model: "Gemini", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 60, ko: "멕시코의 경험이 승리를 견인", en: "Mexico's experience drives the win" },
],
// 남아공 vs 한국 — 16강 길목
A_RSA_KOR_20260625: [
{ model: "GPT", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 58, ko: "한국의 전방 결정력과 큰 무대 경험 우세", en: "Korea's finishing and big-stage edge" },
{ model: "Claude", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 62, ko: "16강 길목, 한국의 화력이 남아공을 압도", en: "With the last-16 on the line, Korea's firepower tells" },
{ model: "Gemini", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 75, ko: "스쿼드 전력 우세한 한국이 경기 주도", en: "Korea's stronger squad dictates the game" },
],
};
export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[] {
if (match.matchId === FEATURED.matchId) {
return FEATURED_PREDICTIONS.map((p) => ({
@ -162,6 +214,19 @@ export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[]
reasonShort: FEATURED_REASONS[lang][p.model],
}));
}
const cur = CURATED[match.matchId];
if (cur) {
return cur.map((c) => ({
matchId: match.matchId,
model: c.model,
outcome: c.outcome,
scoreA: c.scoreA,
scoreB: c.scoreB,
confidencePct: c.confidencePct,
reasonShort: lang === "en" ? c.en : c.ko,
generatedAt: "2026-06-10",
}));
}
return generatePredictions(match, lang);
}

85
lib/myPredictions.ts Normal file
View File

@ -0,0 +1,85 @@
// 내 지난 예측 — 로그인 없이 이메일 기준 조회/저장
// ─────────────────────────────────────────────────────────────
// UI(Arena.tsx)는 아래 4개 함수의 "시그니처(입출력 타입)"에만 의존한다.
// 백엔드 개발자는 fetchMyPredictions / saveMyPrediction 의 *본문만*
// Firebase 콜러블 함수(getMyPredictions / submitPrediction) 호출로 교체하면
// UI 코드는 수정할 필요가 없다. (rememberEmail/recallEmail 은 그대로 클라 보관)
//
// 현재 구현: 데모용 localStorage (기기 1대 기준). 운영에서는 Firestore
// user_predictions 를 이메일로 조회하는 콜러블로 대체 → 기기 간 동기화.
import type { Outcome } from "./types";
/** 이메일 기준으로 조회되는 1건의 사용자 예측 (표시·정렬용 필드 포함) */
export type MyPrediction = {
matchId: string;
teamAShort: string; // "멕시코"
teamBShort: string; // "한국"
kickoffKst: string; // ISO — 정렬/표시용
outcome: Outcome;
scoreA: number;
scoreB: number;
submittedAt: string; // ISO — 제출 시각(최신순 정렬 기준)
/** 경기 종료 시 백엔드가 채움. 미종료면 undefined */
result?: { outcome: Outcome; scoreA: number; scoreB: number; hitOutcome: boolean };
};
const STORE_KEY = "tp_my_predictions_v1"; // { [emailLower]: MyPrediction[] }
const EMAIL_KEY = "tp_email_v1";
const normEmail = (email: string) => email.trim().toLowerCase();
function readStore(): Record<string, MyPrediction[]> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(window.localStorage.getItem(STORE_KEY) || "{}");
} catch {
return {};
}
}
function writeStore(store: Record<string, MyPrediction[]>) {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORE_KEY, JSON.stringify(store));
}
/* ============================================================
*
* : getMyPredictions .
* const { fns } = getFirebase();
* const res = await httpsCallable(fns, "getMyPredictions")({ email });
* return res.data as MyPrediction[]; // 최신순 정렬 보장
* ============================================================ */
export async function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
if (!email) return [];
const list = readStore()[normEmail(email)] ?? [];
// 최신 제출순(내림차순)
return [...list].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
}
/* ============================================================
* /
* : submitPrediction ( crowd_stats
* + PII ). 1(matchId upsert).
* ============================================================ */
export async function saveMyPrediction(email: string, rec: MyPrediction): Promise<void> {
if (!email) return;
const key = normEmail(email);
const store = readStore();
const list = store[key] ?? [];
const next = list.filter((p) => p.matchId !== rec.matchId); // 같은 경기는 최신 픽으로 교체
next.push(rec);
store[key] = next;
writeStore(store);
}
/** 마지막 사용 이메일 기억 (재방문 시 자동 복원 — 로그인 대체) */
export function rememberEmail(email: string) {
if (typeof window === "undefined" || !email) return;
window.localStorage.setItem(EMAIL_KEY, normEmail(email));
}
/** 기억된 이메일 불러오기 (없으면 "") */
export function recallEmail(): string {
if (typeof window === "undefined") return "";
return window.localStorage.getItem(EMAIL_KEY) || "";
}

View File

@ -1,4 +1,4 @@
// 2026 FIFA World Cup Group A 정식 일정 (출처: Wikipedia/FIFA, KST 변환 검증 2026-06-08)
// 2026 글로벌 축구 축전 Group A 정식 일정 (출처: Wikipedia, KST 변환 검증 2026-06-08)
// 모든 kickoffKst는 한국시간(+09:00). lockAt = 킥오프 -10분.
import type { Match, Team } from "./types";
@ -24,9 +24,9 @@ export const GROUP_A: Match[] = [
venue: "Estadio Azteca · Mexico City",
opensAt: opens("2026-06-12T04:00:00+09:00"),
lockAt: "2026-06-12T04:00:00+09:00",
status: "upcoming",
status: "finished",
hookText: "글로벌 축구 개막전, AI는 개최국을 믿을까",
result: null,
result: { scoreA: 2, scoreB: 1, outcome: "TEAM_A_WIN" }, // 데모: 개막전 종료 — 멕시코 2-1 승
},
{
matchId: "A_KOR_CZE_20260612",

View File

@ -0,0 +1 @@
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#d7141a" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 0h900v300H0z"/><path d="M450 300 0 0v600z" fill="#11457e"/></svg>

After

Width:  |  Height:  |  Size: 210 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="-72 -48 144 96"><path fill="#fff" d="M-72-48v96H72v-96z"/><g stroke="#000" stroke-width="4"><path d="M-34.946-37.72-48.26-17.75m4.992 3.328 13.313-19.97m4.992 3.329-13.312 19.969m63.236 42.157 6.101-9.152m1.11-1.664 6.101-9.153m4.993 3.328-6.102 9.153m-1.11 1.664-6.101 9.152m4.992 3.329 6.102-9.153m1.11-1.664 6.1-9.153M-48.259 17.75l13.313 19.97m4.992-3.329-6.102-9.152m-1.109-1.664-6.102-9.153m4.993-3.328 13.312 19.97m63.236-42.158-6.101-9.153m-1.11-1.664-6.101-9.152m4.992-3.328 13.313 19.969m4.992-3.328-6.102-9.153m-1.11-1.664-6.1-9.153"/></g><path fill="#cd2e3a" d="M9.985 6.656A18 18 0 1 1-19.97-13.313a24 24 0 1 1 39.938 26.626"/><path fill="#0047a0" d="M0 0a12 12 0 1 1 19.97 13.313 24 24 0 1 1-39.94-26.626A12 12 0 1 0 0 0"/></svg>

After

Width:  |  Height:  |  Size: 817 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 90 60"><defs><clipPath id="b"><path d="m0 0 45 30L0 60z"/></clipPath><clipPath id="a"><path d="M0 0h90v60H0z"/></clipPath></defs><path fill="#E03C31" d="M0 0h90v30H45z"/><path fill="#001489" d="M0 60h90V30H45z"/><g fill="none" clip-path="url(#a)"><path stroke="#FFF" stroke-width="20" d="M90 30H45L0 0v60l45-30"/><path fill="#000" stroke="#ffb81c" stroke-width="20" d="m0 0 45 30L0 60" clip-path="url(#b)"/><path stroke="#007749" stroke-width="12" d="m0 0 45 30h45M0 60l45-30"/></g></svg>

After

Width:  |  Height:  |  Size: 566 B