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*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (secrets ignored; template tracked)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export default function Before() {
|
||||||
|
|
||||||
{/* Your pick */}
|
{/* Your pick */}
|
||||||
<section id="pick" style={{ marginTop: 20, scrollMarginTop: 80 }}>
|
<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={{ ...card, padding: 16 }}>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, background: C.bg2, borderRadius: 8, padding: 4 }}>
|
<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]) => (
|
{([["A", "한국 승"], ["D", "무승부"], ["B", "체코 승"]] as [string, string][]).map(([v, l]) => (
|
||||||
|
|
@ -210,7 +210,7 @@ export default function Before() {
|
||||||
{/* Prize */}
|
{/* Prize */}
|
||||||
<section style={{ marginTop: 20 }}>
|
<section style={{ marginTop: 20 }}>
|
||||||
<div style={{ ...card, padding: 16 }}>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
@ -220,7 +220,7 @@ export default function Before() {
|
||||||
<footer style={{ marginTop: 32, borderTop: `1px solid ${C.line}`, paddingTop: 20 }}>
|
<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: 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>
|
<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>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
--gold-btn-2: #15a99b; /* 틸그린 */
|
--gold-btn-2: #15a99b; /* 틸그린 */
|
||||||
|
|
||||||
--gpt: #15a99b; /* 틸그린 (teal green) */
|
--gpt: #15a99b; /* 틸그린 (teal green) */
|
||||||
--claude: #e8814a;
|
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
|
||||||
--gemini: #5b8cff;
|
--gemini: #5b8cff;
|
||||||
|
|
||||||
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
||||||
|
|
@ -130,6 +130,15 @@ body {
|
||||||
0 10px 28px rgba(133, 252, 168, 0.28);
|
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 {
|
.gold-card {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(94, 240, 180, 0.08), rgba(21, 169, 155, 0.03)),
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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:
|
description:
|
||||||
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
|
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
|
||||||
applicationName: "TriplePick",
|
applicationName: "TriplePick",
|
||||||
|
|
@ -17,7 +17,7 @@ export const metadata: Metadata = {
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "AI 셋이 갈렸다 — TriplePick 2026",
|
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 type { Metadata } from "next";
|
||||||
import Hero from "@/components/Hero";
|
import Hero from "@/components/Hero";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
|
import Leaderboard from "@/components/Leaderboard";
|
||||||
|
import { parseLang, dict } from "@/lib/i18n";
|
||||||
|
|
||||||
// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub.
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "랭킹 | TriplePick 2026",
|
title: "누적 랭킹 | TriplePick 2026",
|
||||||
description: "TriplePick 누적 랭킹 — 곧 공개됩니다.",
|
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 (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<Hero back />
|
<Hero back backHistory />
|
||||||
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
|
<p className="mt-6 text-center text-[13px] leading-relaxed text-[var(--ink-muted)]">
|
||||||
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
|
{t.goldDesc}
|
||||||
<p className="mt-3 text-[15px] font-bold">랭킹은 곧 공개됩니다</p>
|
|
||||||
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
|
|
||||||
매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께
|
|
||||||
최종 100만원을 드립니다.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
|
||||||
|
<Leaderboard lang={lang} defaultOpen />
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Hero from "@/components/Hero";
|
import Hero from "@/components/Hero";
|
||||||
import MatchupHUD from "@/components/MatchupHUD";
|
import MatchupHUD from "@/components/MatchupHUD";
|
||||||
|
|
@ -63,7 +64,7 @@ export default async function MatchPage({
|
||||||
text:
|
text:
|
||||||
lang === "en"
|
lang === "en"
|
||||||
? `${hook} — see all 3 AI picks and make yours!`
|
? `${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)}
|
shareUrl={matchUrl(match.matchId)}
|
||||||
lang={lang}
|
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} />
|
<Footer lang={lang} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
|
||||||
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,8 +14,16 @@ import type {
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import Countdown from "./Countdown";
|
import Countdown from "./Countdown";
|
||||||
import TeamFlag from "./TeamFlag";
|
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> = {
|
const MODEL_ICON: Record<ModelName, string> = {
|
||||||
GPT: "/icons/gpt.png",
|
GPT: "/icons/gpt.png",
|
||||||
|
|
@ -25,6 +33,12 @@ const MODEL_ICON: Record<ModelName, string> = {
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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({
|
export default function Arena({
|
||||||
match,
|
match,
|
||||||
predictions,
|
predictions,
|
||||||
|
|
@ -41,14 +55,44 @@ export default function Arena({
|
||||||
const t = dict(lang);
|
const t = dict(lang);
|
||||||
const aShort = teamShort(match.teamA, lang);
|
const aShort = teamShort(match.teamA, lang);
|
||||||
const bShort = teamShort(match.teamB, 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 [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||||
const [scoreA, setScoreA] = useState(2);
|
const [scoreA, setScoreA] = useState(2);
|
||||||
const [scoreB, setScoreB] = useState(1);
|
const [scoreB, setScoreB] = useState(1);
|
||||||
const [step, setStep] = useState<Step>("pick");
|
const [step, setStep] = useState<Step>("form");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [notify, setNotify] = useState(true);
|
const [notify, setNotify] = useState(true);
|
||||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||||
const [copied, setCopied] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -78,12 +122,11 @@ export default function Arena({
|
||||||
.map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB }));
|
.map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB }));
|
||||||
}, [outcome, scoreA, scoreB, predictions]);
|
}, [outcome, scoreA, scoreB, predictions]);
|
||||||
|
|
||||||
const submitPick = () => {
|
const emailValid = EMAIL_RE.test(email.trim());
|
||||||
if (!outcome) return;
|
const confirmSubmit = async () => {
|
||||||
setStep("form");
|
if (!emailValid || !outcome) return;
|
||||||
};
|
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
|
||||||
const confirmSubmit = () => {
|
if (!myThisMatch) {
|
||||||
if (!EMAIL_RE.test(email.trim())) return;
|
|
||||||
setCrowd((c) => ({
|
setCrowd((c) => ({
|
||||||
...c,
|
...c,
|
||||||
total: c.total + 1,
|
total: c.total + 1,
|
||||||
|
|
@ -91,6 +134,20 @@ export default function Arena({
|
||||||
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
|
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
|
||||||
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 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");
|
setStep("done");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,8 +161,8 @@ export default function Arena({
|
||||||
: `I picked ${me} — different from all 3 AIs! You? — TriplePick`;
|
: `I picked ${me} — different from all 3 AIs! You? — TriplePick`;
|
||||||
}
|
}
|
||||||
return sameAI
|
return sameAI
|
||||||
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
|
? `나는 ${me}. ${sameAI}와 같은 선택! 당신은? — TriplePick`
|
||||||
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
: `나는 ${me}. AI 셋과 다 다른 선택! 당신은? — TriplePick`;
|
||||||
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
|
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
|
|
@ -142,8 +199,14 @@ export default function Arena({
|
||||||
|
|
||||||
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
||||||
<section className="sheet mt-4 p-5 text-[var(--ink)]">
|
<section className="sheet mt-4 p-5 text-[var(--ink)]">
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex items-end justify-between gap-2">
|
||||||
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
@ -255,43 +318,45 @@ export default function Arena({
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */}
|
{/* ===== 투표 마감/오픈 전: 상태 버튼만 ===== */}
|
||||||
{!finished && step === "pick" && (
|
{!finished && disabled && (
|
||||||
<button
|
<button
|
||||||
onClick={submitPick}
|
disabled
|
||||||
disabled={!outcome || disabled}
|
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold opacity-60"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== 폼: 이메일만 (D5, 닉네임 제거) ===== */}
|
{/* ===== 투표 제출 (D5): 이메일 + 단일 CTA를 한 카드로 통합 ===== */}
|
||||||
{!finished && step === "form" && (
|
{!finished && !disabled && step === "form" && (
|
||||||
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
placeholder={t.emailPh}
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={notify}
|
checked={notify}
|
||||||
onChange={(e) => setNotify(e.target.checked)}
|
onChange={(e) => setNotify(e.target.checked)}
|
||||||
className="mt-0.5 accent-[var(--mint)]"
|
className="mt-0.5 accent-[var(--share)]"
|
||||||
/>
|
/>
|
||||||
{t.notify}
|
{t.notify}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={confirmSubmit}
|
onClick={confirmSubmit}
|
||||||
disabled={!EMAIL_RE.test(email.trim())}
|
disabled={!emailValid}
|
||||||
className="btn-mint w-full rounded-xl py-3.5 text-[17px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
|
className="btn-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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -325,6 +390,78 @@ export default function Arena({
|
||||||
</div>
|
</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) ===== */}
|
{/* ===== 골드 상금 (003) ===== */}
|
||||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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>
|
||||||
|
|
||||||
<div className="mt-3 text-[10px] text-white/70">
|
<div className="mt-3 text-[10px] text-white/70">
|
||||||
© 2026 TriplePick · 운영 AIO2O · @triplepick_ai
|
© 2026 TriplePick · 운영 AIO2O · @triplepickai
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
import LangSwitch from "./LangSwitch";
|
import LangSwitch from "./LangSwitch";
|
||||||
|
import BackButton from "./BackButton";
|
||||||
import { type Lang, dict } from "@/lib/i18n";
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
|
|
||||||
type Share = { url: string; title: string; text: string };
|
type Share = { url: string; title: string; text: string };
|
||||||
|
|
@ -8,10 +9,12 @@ type Share = { url: string; title: string; text: string };
|
||||||
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
|
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
|
||||||
export default function Hero({
|
export default function Hero({
|
||||||
back = false,
|
back = false,
|
||||||
|
backHistory = false,
|
||||||
share,
|
share,
|
||||||
lang = "ko",
|
lang = "ko",
|
||||||
}: {
|
}: {
|
||||||
back?: boolean;
|
back?: boolean;
|
||||||
|
backHistory?: boolean; // true면 홈 대신 직전 페이지로 복귀(뒤로가기)
|
||||||
share?: Share;
|
share?: Share;
|
||||||
lang?: Lang;
|
lang?: Lang;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -21,12 +24,16 @@ export default function Hero({
|
||||||
<header className="pt-5 text-center">
|
<header className="pt-5 text-center">
|
||||||
{back && (
|
{back && (
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
{backHistory ? (
|
||||||
|
<BackButton label={t.backPrev} home={home} />
|
||||||
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={home}
|
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}
|
{t.back}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
{share && (
|
{share && (
|
||||||
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
|
<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.d) split.push(`${t.draw} ${tally.d}`);
|
||||||
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
|
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" : ""}`;
|
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -74,6 +84,9 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
|
||||||
<span className="font-mono font-bold text-white/70">
|
<span className="font-mono font-bold text-white/70">
|
||||||
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
|
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
|
||||||
{tRound(match.roundLabel, lang)}
|
{tRound(match.roundLabel, lang)}
|
||||||
|
{winLabel && (
|
||||||
|
<span className="ml-1 font-semibold text-[#94FBE0]">· {winLabel}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
|
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
|
||||||
{t.phase[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" />
|
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
|
||||||
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
|
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
|
||||||
</div>
|
</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>
|
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
|
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
|
||||||
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
|
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default function ShareButton({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onShare}
|
onClick={onShare}
|
||||||
className="flex items-center rounded-full border border-[var(--share)]/55 bg-[var(--share)]/12 px-3 py-1.5 text-[12px] font-bold text-[var(--share)] transition active:scale-95"
|
className="flex items-center rounded-full border border-[var(--share)]/55 bg-[var(--share)]/12 px-[18px] py-[9px] text-[18px] font-bold text-[var(--share)] transition active:scale-95"
|
||||||
>
|
>
|
||||||
{copied ? copiedLabel : label}
|
{copied ? copiedLabel : label}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import type { Team } from "@/lib/types";
|
import type { Team } from "@/lib/types";
|
||||||
|
|
||||||
// 모든 경기에 일반화된 국기 렌더.
|
// 모든 국기를 직사각형 SVG로 통일 (flagcdn, 퍼블릭 도메인).
|
||||||
// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
|
// 이모지(OS별 휘날림/광택 렌더) 제거 → 일관된 사각형. object-cover로 박스를 꽉 채움(왜곡 없음).
|
||||||
|
const FLAG_FILE: Record<string, string> = {
|
||||||
|
KOR: "kr",
|
||||||
|
CZE: "cz",
|
||||||
|
MEX: "mx",
|
||||||
|
RSA: "za",
|
||||||
|
};
|
||||||
|
|
||||||
export default function TeamFlag({
|
export default function TeamFlag({
|
||||||
team,
|
team,
|
||||||
className = "",
|
className = "",
|
||||||
|
|
@ -9,30 +16,17 @@ export default function TeamFlag({
|
||||||
team: Team;
|
team: Team;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
if (team.code === "KOR") {
|
const file = FLAG_FILE[team.code];
|
||||||
|
if (file) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src="/icons/kor.png"
|
src={`/icons/flags/${file}.svg`}
|
||||||
alt={team.name}
|
alt={team.name}
|
||||||
className={`flag-img object-cover ${className}`}
|
className={`border border-[var(--line-d)] object-cover ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (team.code === "CZE") {
|
// 미등록 국가 fallback — 이모지(직사각형 박스, 잘림 없음)
|
||||||
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) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`grid place-items-center overflow-hidden border border-[var(--line-d)] bg-white/[0.06] ${className}`}
|
className={`grid place-items-center overflow-hidden border border-[var(--line-d)] bg-white/[0.06] ${className}`}
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 7. Do / Don't
|
||||||
- ✅ 실제 모델 아이콘 · 클래식 축구공(투명) · 라이트닝 그린 VS · 화이트 시트 + 다크 하이브리드 · 민트 CTA
|
- ✅ 실제 모델 아이콘 · 클래식 축구공(투명) · 라이트닝 그린 VS · 화이트 시트 + 다크 하이브리드 · 민트 CTA
|
||||||
- ✅ 3색(틸그린/오렌지/블루)으로 모델 구분, 글로우는 그린
|
- ✅ 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 = {
|
type Dict = {
|
||||||
heroPill: string;
|
heroPill: string;
|
||||||
back: string;
|
back: string;
|
||||||
|
backPrev: string;
|
||||||
share: string;
|
share: string;
|
||||||
shareCopied: string;
|
shareCopied: string;
|
||||||
introLead: string;
|
introLead: string;
|
||||||
|
|
@ -62,6 +63,7 @@ type Dict = {
|
||||||
ctaResult: string;
|
ctaResult: string;
|
||||||
ctaLocked: string;
|
ctaLocked: string;
|
||||||
ctaOpens: (d: string) => string;
|
ctaOpens: (d: string) => string;
|
||||||
|
emailGate: string;
|
||||||
emailPh: string;
|
emailPh: string;
|
||||||
notify: string;
|
notify: string;
|
||||||
submit: string;
|
submit: string;
|
||||||
|
|
@ -91,12 +93,28 @@ type Dict = {
|
||||||
ado2Tagline: string;
|
ado2Tagline: string;
|
||||||
ado2Sub: string;
|
ado2Sub: string;
|
||||||
ado2Cta: 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> = {
|
export const DICT: Record<Lang, Dict> = {
|
||||||
ko: {
|
ko: {
|
||||||
heroPill: "AI와 함께하는 2026 월드컵 승부예측 챌린지",
|
heroPill: "AI와 함께하는 2026 글로벌 축구 축전 승부예측 챌린지",
|
||||||
back: "← 전체 일정",
|
back: "← 전체 일정",
|
||||||
|
backPrev: "← 투표로 돌아가기",
|
||||||
share: "투표 공유하기",
|
share: "투표 공유하기",
|
||||||
shareCopied: "링크 복사됨 ✓",
|
shareCopied: "링크 복사됨 ✓",
|
||||||
introLead: "GPT · Claude · Gemini가 매 경기를\n서로 다르게 예측합니다.",
|
introLead: "GPT · Claude · Gemini가 매 경기를\n서로 다르게 예측합니다.",
|
||||||
|
|
@ -104,7 +122,7 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
prizeLine2: "최종 우승자에게 100만원 상금",
|
prizeLine2: "최종 우승자에게 100만원 상금",
|
||||||
schedTitle: "전체 일정",
|
schedTitle: "전체 일정",
|
||||||
schedGuide: "투표하고 싶은 경기를 선택해주세요",
|
schedGuide: "투표하고 싶은 경기를 선택해주세요",
|
||||||
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "종료" },
|
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "투표종료" },
|
||||||
aiPicks: "AI 픽",
|
aiPicks: "AI 픽",
|
||||||
draw: "무",
|
draw: "무",
|
||||||
joined: (n) => `${n}명 참여`,
|
joined: (n) => `${n}명 참여`,
|
||||||
|
|
@ -119,9 +137,10 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
ctaResult: "결과 보기",
|
ctaResult: "결과 보기",
|
||||||
ctaLocked: "예측 마감 (경기 시작)",
|
ctaLocked: "예측 마감 (경기 시작)",
|
||||||
ctaOpens: (d) => `투표 오픈 ${d}`,
|
ctaOpens: (d) => `투표 오픈 ${d}`,
|
||||||
emailPh: "이메일 (결과·다음 경기 알림)",
|
emailGate: "투표를 제출하려면 이메일을 입력하세요",
|
||||||
notify: "경기 결과가 나오면 알려주세요. 다음 경기·100만원 챌린지 소식도 받겠습니다.",
|
emailPh: "이메일 입력 → 투표 결과·다음 경기 알림",
|
||||||
submit: "제출하고 AI와 겨루기",
|
notify: "결과 알림 + 다음 경기 소식 + ₩100만 챌린지 기회까지 무료로 받기",
|
||||||
|
submit: "투표 제출하고 AI와 겨루기",
|
||||||
myPick: "내 예측",
|
myPick: "내 예측",
|
||||||
sameAI: (m, exact) =>
|
sameAI: (m, exact) =>
|
||||||
`당신은 ${m}와 같은 선택입니다.${exact ? " 스코어까지 일치!" : ""} 나머지 AI는 생각이 다릅니다.`,
|
`당신은 ${m}와 같은 선택입니다.${exact ? " 스코어까지 일치!" : ""} 나머지 AI는 생각이 다릅니다.`,
|
||||||
|
|
@ -141,19 +160,35 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
final: "Final",
|
final: "Final",
|
||||||
group: (g) => `Group ${g}`,
|
group: (g) => `Group ${g}`,
|
||||||
footerNotOfficial:
|
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: "FIFA 및 공식 월드컵과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).",
|
footerDisc1: "FIFA 및 공식 축구 대회와 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).",
|
||||||
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
|
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
|
||||||
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
||||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
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: "광고",
|
adLabel: "광고",
|
||||||
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
||||||
ado2Sub: "AI Marketing Automation Platform →",
|
ado2Sub: "AI Marketing Automation Platform →",
|
||||||
ado2Cta: "사용해보기",
|
ado2Cta: "사용해보기",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
heroPill: "AI-powered World Cup 2026 match predictions",
|
heroPill: "AI-powered Global Football Festival 2026 match predictions",
|
||||||
back: "← All matches",
|
back: "← All matches",
|
||||||
|
backPrev: "← Back to voting",
|
||||||
share: "Share vote",
|
share: "Share vote",
|
||||||
shareCopied: "Link copied ✓",
|
shareCopied: "Link copied ✓",
|
||||||
introLead: "GPT · Claude · Gemini predict\nevery match differently.",
|
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",
|
prizeLine2: "overall winner takes ₩1,000,000",
|
||||||
schedTitle: "All Matches",
|
schedTitle: "All Matches",
|
||||||
schedGuide: "Pick a match to predict",
|
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",
|
aiPicks: "AI picks",
|
||||||
draw: "Draw",
|
draw: "Draw",
|
||||||
joined: (n) => `${n} joined`,
|
joined: (n) => `${n} joined`,
|
||||||
|
|
@ -176,9 +211,10 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
ctaResult: "View result",
|
ctaResult: "View result",
|
||||||
ctaLocked: "Closed (kickoff)",
|
ctaLocked: "Closed (kickoff)",
|
||||||
ctaOpens: (d) => `Opens ${d}`,
|
ctaOpens: (d) => `Opens ${d}`,
|
||||||
emailPh: "Email (result & next-match alerts)",
|
emailGate: "Enter your email to submit your vote",
|
||||||
notify: "Notify me when the result is out. I'll also get next-match & ₩1,000,000 challenge news.",
|
emailPh: "Your email → result & next-match alerts",
|
||||||
submit: "Submit & face the AI",
|
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",
|
myPick: "My Pick",
|
||||||
sameAI: (m, exact) =>
|
sameAI: (m, exact) =>
|
||||||
`You matched ${m}.${exact ? " Exact score too!" : ""} The other AIs disagree.`,
|
`You matched ${m}.${exact ? " Exact score too!" : ""} The other AIs disagree.`,
|
||||||
|
|
@ -198,11 +234,26 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
final: "Final",
|
final: "Final",
|
||||||
group: (g) => `Group ${g}`,
|
group: (g) => `Group ${g}`,
|
||||||
footerNotOfficial:
|
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.",
|
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.",
|
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.",
|
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}`,
|
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",
|
adLabel: "AD",
|
||||||
ado2Tagline: "AI Marketing Automation Platform",
|
ado2Tagline: "AI Marketing Automation Platform",
|
||||||
ado2Sub: "Visit ado2.o2osolution.ai →",
|
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 { FEATURED, GROUP_A } from "./schedule";
|
||||||
import { type Lang, teamShort } from "./i18n";
|
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>> = {
|
const FEATURED_REASONS: Record<Lang, Record<ModelName, string>> = {
|
||||||
ko: {
|
ko: {
|
||||||
GPT: "한국 역습과 체코 제공권이 팽팽",
|
GPT: "전력과 압박 강도 모두 팽팽",
|
||||||
Claude: "손흥민·이강인 측면 창의성이 체코 블록을 연다",
|
Claude: "양 팀 전력 비슷, 박빙 무승부",
|
||||||
Gemini: "이강인·손흥민 공격력, 체코 수비에 근소 우세",
|
Gemini: "이강인·손흥민 공격력, 체코 수비에 근소 우세",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
GPT: "Korea's counters vs Czech aerial — dead even",
|
GPT: "Power and pressing — dead even",
|
||||||
Claude: "Son & Lee's wing play cracks the Czech block",
|
Claude: "Evenly matched, a tight draw likely",
|
||||||
Gemini: "Korea's attack edges a stubborn Czech defense",
|
Gemini: "Korea's attack edges a stubborn Czech defense",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -32,31 +33,31 @@ export const DEMO_FORCE_OPEN = true;
|
||||||
const FEATURED_PREDICTIONS: AIPrediction[] = [
|
const FEATURED_PREDICTIONS: AIPrediction[] = [
|
||||||
{
|
{
|
||||||
matchId: MATCH.matchId,
|
matchId: MATCH.matchId,
|
||||||
model: "GPT", // gpt-5.5: 무승부 1-1
|
model: "GPT", // gpt-5.5: 무승부 1-1 (conf 45)
|
||||||
outcome: "DRAW",
|
outcome: "DRAW",
|
||||||
scoreA: 1,
|
scoreA: 1,
|
||||||
scoreB: 1,
|
scoreB: 1,
|
||||||
confidencePct: 54,
|
confidencePct: 45,
|
||||||
reasonShort: FEATURED_REASONS.ko.GPT,
|
reasonShort: FEATURED_REASONS.ko.GPT,
|
||||||
generatedAt: "2026-06-10",
|
generatedAt: "2026-06-10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchId: MATCH.matchId,
|
matchId: MATCH.matchId,
|
||||||
model: "Claude", // claude-opus-4-8: 한국 승 2-1
|
model: "Claude", // claude-opus-4-8: 무승부 1-1 (conf 55)
|
||||||
outcome: "TEAM_A_WIN",
|
outcome: "DRAW",
|
||||||
scoreA: 2,
|
scoreA: 1,
|
||||||
scoreB: 1,
|
scoreB: 1,
|
||||||
confidencePct: 57,
|
confidencePct: 55,
|
||||||
reasonShort: FEATURED_REASONS.ko.Claude,
|
reasonShort: FEATURED_REASONS.ko.Claude,
|
||||||
generatedAt: "2026-06-10",
|
generatedAt: "2026-06-10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchId: MATCH.matchId,
|
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",
|
outcome: "TEAM_A_WIN",
|
||||||
scoreA: 2,
|
scoreA: 2,
|
||||||
scoreB: 1,
|
scoreB: 1,
|
||||||
confidencePct: 55,
|
confidencePct: 60,
|
||||||
reasonShort: FEATURED_REASONS.ko.Gemini,
|
reasonShort: FEATURED_REASONS.ko.Gemini,
|
||||||
generatedAt: "2026-06-10",
|
generatedAt: "2026-06-10",
|
||||||
},
|
},
|
||||||
|
|
@ -74,6 +75,13 @@ function hash(s: string): number {
|
||||||
|
|
||||||
const MODELS: ModelName[] = ["GPT", "Claude", "Gemini"];
|
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 셋이 갈리도록 만든 결과 조합 (홈/원정 균형)
|
// AI 셋이 갈리도록 만든 결과 조합 (홈/원정 균형)
|
||||||
const OUTCOME_SETS: Outcome[][] = [
|
const OUTCOME_SETS: Outcome[][] = [
|
||||||
["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"],
|
["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[] {
|
export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[] {
|
||||||
if (match.matchId === FEATURED.matchId) {
|
if (match.matchId === FEATURED.matchId) {
|
||||||
return FEATURED_PREDICTIONS.map((p) => ({
|
return FEATURED_PREDICTIONS.map((p) => ({
|
||||||
|
|
@ -162,6 +214,19 @@ export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[]
|
||||||
reasonShort: FEATURED_REASONS[lang][p.model],
|
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);
|
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분.
|
// 모든 kickoffKst는 한국시간(+09:00). lockAt = 킥오프 -10분.
|
||||||
import type { Match, Team } from "./types";
|
import type { Match, Team } from "./types";
|
||||||
|
|
||||||
|
|
@ -24,9 +24,9 @@ export const GROUP_A: Match[] = [
|
||||||
venue: "Estadio Azteca · Mexico City",
|
venue: "Estadio Azteca · Mexico City",
|
||||||
opensAt: opens("2026-06-12T04:00:00+09:00"),
|
opensAt: opens("2026-06-12T04:00:00+09:00"),
|
||||||
lockAt: "2026-06-12T04:00:00+09:00",
|
lockAt: "2026-06-12T04:00:00+09:00",
|
||||||
status: "upcoming",
|
status: "finished",
|
||||||
hookText: "글로벌 축구 개막전, AI는 개최국을 믿을까",
|
hookText: "글로벌 축구 개막전, AI는 개최국을 믿을까",
|
||||||
result: null,
|
result: { scoreA: 2, scoreB: 1, outcome: "TEAM_A_WIN" }, // 데모: 개막전 종료 — 멕시코 2-1 승
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchId: "A_KOR_CZE_20260612",
|
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