Compare commits
17 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
996d5faaf4 | |
|
|
965bd374c3 | |
|
|
b69b1dbe97 | |
|
|
cbe0e8e01d | |
|
|
cbab4a102a | |
|
|
c8431e65f3 | |
|
|
7d4d50af40 | |
|
|
6c3a47b6ba | |
|
|
0c91c902a8 | |
|
|
7a375ee32f | |
|
|
596ed9c094 | |
|
|
6a094debbc | |
|
|
9de27dafd8 | |
|
|
f29dbd0454 | |
|
|
9b35a759a9 | |
|
|
2fe60ab0cd | |
|
|
0a15906afb |
|
|
@ -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 파싱
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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와 겨뤄요.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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만원을 드립니다.
|
||||
<Hero back backHistory />
|
||||
<p className="mt-6 text-center text-[13px] leading-relaxed text-[var(--ink-muted)]">
|
||||
{t.goldDesc}
|
||||
</p>
|
||||
</section>
|
||||
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
|
||||
<Leaderboard lang={lang} defaultOpen />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,12 +122,11 @@ 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;
|
||||
const emailValid = EMAIL_RE.test(email.trim());
|
||||
const confirmSubmit = async () => {
|
||||
if (!emailValid || !outcome) return;
|
||||
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
|
||||
if (!myThisMatch) {
|
||||
setCrowd((c) => ({
|
||||
...c,
|
||||
total: c.total + 1,
|
||||
|
|
@ -91,6 +134,20 @@ export default function Arena({
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{backHistory ? (
|
||||
<BackButton label={t.backPrev} home={home} />
|
||||
) : (
|
||||
<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"
|
||||
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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
{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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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 자동 호출 포함
|
||||
|
|
@ -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에서 한글 텍스트만 오버레이. 새 경기는 썸네일 베이스 동물만 교체(한국=호랑이 고정).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
77
lib/i18n.ts
77
lib/i18n.ts
|
|
@ -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 →",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) || "";
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
Loading…
Reference in New Issue