Multi-match restructure + 06.09 meeting reflection + scoring design

구조:
- 단일 경기 → 멀티 경기: 대시보드(/) + 경기 상세(/match/[matchId], 6경기 SSG) + 리더보드 stub(/leaderboard)
- 컴포넌트 파라미터화: MatchupHUD/Arena가 match·predictions·crowd props 수신 (싱글톤 의존 제거)
- 신규 컴포넌트: ScheduleBoard, Countdown, TeamFlag
- mockData: 경기별 결정론적 AI예측/Crowd 생성기(getPredictions/getCrowd) + matchUrl

06.09 회의 반영:
- 확률 라벨 "OO 승리 확률 %"+승리팀 국기(D3), confidence 바 제거 + 메인 리즌 강조
- 닉네임 제거→이메일만(D5), 투표 종료 카운트다운(D7), 결과 보기 전환, 경기별 공유(D8)
- "월드컵" 표현 제거→글로벌 축구(D9), 서브타이틀 중복 삭제, Crowd "AI 승리 확률과 다름" 라벨
- 포인트 컬러 #4AFFA0 통일, 일정 #94FBE0, 푸터 가시성 개선

채점·심사 설계(docs/SCORING.md):
- 공정성 모델 확정: 주최측 배제 + 순수 누적 포인트(점수는 정확도에서만)
- 배점/랭킹/배제/결과정의 + scorePrediction·rank 참조 구현
- 골드 카드 심사 기준 카피 정합

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Haewon Kam 2026-06-09 14:05:14 +09:00
parent 0a96babc98
commit a0fc9d6194
19 changed files with 925 additions and 244 deletions

View File

@ -7,19 +7,24 @@
1. **이 파일** (현재 상태·파일맵·실행)
2. `docs/DESIGN.md`**룩앤필/디자인 시스템 SSOT** (컬러·폰트·컴포넌트·자산·Do/Don't)
3. `../Worldcup 2026/000. Plan/Task — 랜딩·디자인·프론트엔드 업데이트 (06.09 회의).md`**할 일 전체(우선순위·금요일 스코프)**
4. `docs/BACKEND.md` — 백엔드(Firestore·함수·채점·Resend·투표창)
5. (참고) `../Worldcup 2026/000. Plan/Landing 기능정의서 — TriplePick 2026.md` / `Landing PRD — TriplePick 2026.md`
4. `docs/SCORING.md`**참여자 채점·심사 SSOT** (공정성 3원칙·배점·랭킹·100만원 심사) ★신규
5. `docs/BACKEND.md` — 백엔드(Firestore·함수·채점·Resend·투표창)
6. (참고) `../Worldcup 2026/000. Plan/Landing 기능정의서 — TriplePick 2026.md` / `Landing PRD — TriplePick 2026.md`
## 0. 프로젝트
TriplePick = "AI(GPT·Claude·Gemini) vs 너" 글로벌 축구 승부예측 랜딩. (※ "월드컵" 명칭 법적 사용불가)
픽 제출 → 같은 AI 확인 → 공유 → 포인트/100만원 챌린지. 모바일 우선.
## 1. 현재 상태 (DONE)
- Next.js 16 + React 19 + Tailwind v4 (App Router, TS). `npm run build` 통과.
- **단일 경기(한국 vs 체코) v2** 구현 — 디자인 002/003 + 사용자 디렉션 반영(Impact, 민트 #85FCA8, 틸그린 GPT, 클래식 축구공, 화이트 시트, 쿨톤 상금).
- 인터랙션: 픽 → (닉네임)이메일 → 제출 → 결과(같은 AI) → Crowd 증가 (전부 **mock**, 백엔드 미연결).
- 배포: https://triplepick-web.vercel.app (`/` = 현재, `/before` = v1 기록용).
- git: `fa68169` init → `5ba653c` /before → `1b9dbd2` Backend Pack → (투표창) 커밋들.
- Next.js 16 + React 19 + Tailwind v4 (App Router, TS). `npm run build` 통과(12 routes, 6경기 SSG).
- **멀티 경기 구조 (06.09 회의 반영, 2026-06-09)** — 대시보드 + 경기 상세 + 리더보드 stub. 프론트엔드 §1·§2·§3 완료.
- `/` = 전체 일정 대시보드, `/match/[matchId]` = 경기 상세(6경기), `/leaderboard` = 차기 stub, `/before` = v1 기록용.
- 상세: 확률 라벨 "OO 승리 확률 %"+국기, 메인 리즌 강조, 이메일 전용 폼, **투표 종료 카운트다운**, 경기별 공유, 결과 보기 분기.
- "월드컵" 표현 전면 제거 → "글로벌 축구". Crowd엔 "AI 승리 확률과 다름" 라벨.
- 디자인 002/003 토큰 유지(Impact, 민트 #85FCA8, 틸그린 GPT, 클래식 축구공, 화이트 시트, 쿨톤 상금).
- 데이터 전부 **mock**(`getPredictions`/`getCrowd`, 결정론 생성), 백엔드 미연결.
- 배포: https://triplepick-web.vercel.app
- git: `fa68169` init → `5ba653c` /before → `1b9dbd2` Backend Pack → (투표창) → **(멀티경기 06.09) 커밋 대기**.
## 2. 실행
```bash
@ -30,19 +35,25 @@ npm run dev -- --port 3737 # http://localhost:3737
## 3. 파일 맵 (현행)
```
app/layout.tsx 메타·OG·SEO(ko)
app/page.tsx Hero → MatchupHUD → Arena → Footer (단일 경기)
app/before/page.tsx /before (v1 기록용, 자체완결)
app/globals.css 디자인 토큰 (DESIGN.md와 1:1)
lib/types.ts 도메인 타입 (opensAt/lockAt 포함)
lib/schedule.ts Group A 6경기 일정(KST) SSOT — opensAt=킥오프-48h, lockAt=킥오프
lib/mockData.ts ⚠️ MOCK AI예측/Crowd + DEMO_FORCE_OPEN 플래그
lib/format.ts 시간·라벨 포맷
lib/firebase.ts 클라 초기화 스켈레톤(env)
components/Hero.tsx · MatchupHUD.tsx · Arena.tsx(핵심) · BallIcon.tsx · Footer.tsx
app/layout.tsx 메타·OG·SEO(ko, 브랜드 레벨)
app/page.tsx L1 대시보드: Hero → ScheduleBoard → Footer
app/match/[matchId]/page.tsx L2 경기 상세(SSG, generateStaticParams 6경기) + generateMetadata
app/leaderboard/page.tsx L3 리더보드 stub (차기)
app/before/page.tsx /before (v1 기록용, 자체완결)
app/globals.css 디자인 토큰 (DESIGN.md와 1:1)
lib/types.ts 도메인 타입 (opensAt/lockAt 포함)
lib/schedule.ts Group A 6경기 SSOT + matchPhase() + matchesByDate()
lib/mockData.ts ⚠️ MOCK — getPredictions()/getCrowd() 경기별, matchUrl(), DEMO_FORCE_OPEN
lib/format.ts 시간·라벨 + winProb()(D3) + dateHeading/timeOnly/dateKey
lib/firebase.ts 클라 초기화 스켈레톤(env)
components/
Hero.tsx 브랜드 헤더(back 옵션) ScheduleBoard.tsx 대시보드 경기 리스트
MatchupHUD.tsx 대결 카드({match}) Arena.tsx 핵심({match,predictions,crowd,shareUrl})
Countdown.tsx 투표 종료 카운트다운(D7) TeamFlag.tsx 국기 일반화(KOR png/CZE svg/이모지)
BallIcon.tsx · Footer.tsx
public/icons/ ball.png(투명) profile.png gpt/claude/gemini kor.png
firestore.rules · firestore.indexes.json · firebase.json · .env.example · scripts/seed.mjs
docs/DESIGN.md · docs/BACKEND.md
docs/DESIGN.md · docs/SCORING.md(채점·심사 SSOT) · docs/BACKEND.md
```
## 4. 확정 결정 (구현에 반영됨/반영할 것)
@ -50,14 +61,16 @@ docs/DESIGN.md · docs/BACKEND.md
- **AI 예측**: 매일 KST 0시 1회 생성, 그날 고정(immutable), 남은 경기만. (현재 mock)
- **결과 메일**: 경기 3시간 후, **맞춘 사람만** + 다음경기/100만원 링크 (Resend).
- **Firebase**: 사용자가 새 프로젝트 직접 생성 → config 6키·서비스계정·Resend키 제공 대기.
- **상금 약관(포인트 배점 수치)**: 미정 → 팀 결정 후 dev. R2.
- **채점·심사(100만원)**: **주최측 배제 + 순수 누적 포인트**(점수는 정확도에서만) 확정 → `docs/SCORING.md`. 배점 수치(5/3/2/1/0)만 황 본부장 확정 대기.
## 5. 다음 세션 할 일 (우선순위)
### 🔴 A. 멀티 경기 구조 (06.09 회의 최대 변경)
- 단일 `/`**전체 일정 대시보드** + `/match/[matchId]` 상세 + (차기)`/leaderboard`
- `lib/schedule.ts` 6경기를 소스로 대시보드/동적 상세 렌더
### 🔴 B. 경기 상세 변경 (금요일 핵심)
- 확률 라벨 "승리 예측 팀 승리 확률"로, 닉네임 제거(이메일만), 서브타이틀 삭제, 투표 카운트다운 복원, 예측하기→결과보기 전환, 경기별 공유
### ✅ A. 멀티 경기 구조 — DONE (2026-06-09)
- 대시보드(`/`) + `/match/[matchId]` 상세(6경기 SSG) + `/leaderboard` stub. `schedule.ts` 6경기 소스.
### ✅ B. 경기 상세 변경 — DONE (2026-06-09)
- 확률 라벨(D3)·이메일만(D5)·서브타이틀 삭제·카운트다운 복원(D7)·결과보기 분기·경기별 공유(D8) 전부 반영.
### 🔴 B'. 백엔드 연동 대기 데이터 (팀 결정 → dev)
- **채점 로직**: `docs/SCORING.md` 그대로 구현(`scorePrediction`/`rank`). 모델=주최측 배제+순수 누적 포인트 확정. **배점 수치(5/3/2/1/0)만** 황 본부장 확정 후 `SCORE` 상수 교체.
- **AI reason 실제 모델 출력**으로 mock 교체. **결과 메일 3시간 후**(D6) 파이프라인.
### 🟠 C. Firebase 연결 (BACKEND.md 그대로)
- `npm i firebase`(설치됨) → `.env.local` 6키 → `mockData` 읽기 → Firestore, `Arena.confirmSubmit` mock → `submitPrediction` 콜러블
- `firebase deploy --only firestore:rules,firestore:indexes``node scripts/seed.mjs` → functions 생성/구현

View File

@ -5,7 +5,7 @@
--bg2: #1b1f26;
--mint: #85fca8; /* CTA 버튼 */
--mint-ink: #0a2417; /* 민트 버튼 위 텍스트 */
--green: #3ee08a; /* 글로우/액센트 */
--green: #4affa0; /* 글로우/액센트 (포인트 컬러) */
--sheet: #f2f3ef; /* 레이어드 화이트 시트 */
--sheet-card: #ffffff;
@ -116,8 +116,8 @@ body {
.vs-glow {
background: radial-gradient(
closest-side,
rgba(62, 224, 138, 0.5),
rgba(62, 224, 138, 0) 70%
rgba(74, 255, 160, 0.5),
rgba(74, 255, 160, 0) 70%
);
}

View File

@ -2,12 +2,12 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "한국 vs 체코 AI 승부예측 | GPT vs Claude vs Gemini — TriplePick",
title: "TriplePick 2026 | AI 글로벌 축구 승부예측 — GPT vs Claude vs Gemini",
description:
"GPT·Claude·Gemini가 한국 vs 체코를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 맞히면 100만 원 챌린지.",
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 맞히면 100만 원 챌린지.",
applicationName: "TriplePick",
openGraph: {
title: "AI 셋이 갈렸다 — 한국 vs 체코, 당신의 픽은?",
title: "AI 셋이 갈렸다 — 당신의 픽은?",
description:
"GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어보세요. TriplePick AI 예측 아레나.",
siteName: "TriplePick",
@ -16,7 +16,7 @@ export const metadata: Metadata = {
},
twitter: {
card: "summary_large_image",
title: "AI 셋이 갈렸다 — 한국 vs 체코",
title: "AI 셋이 갈렸다 — TriplePick 2026",
description: "GPT vs Claude vs Gemini vs 너. 지금 픽하고 AI와 겨뤄요.",
},
};

26
app/leaderboard/page.tsx Normal file
View File

@ -0,0 +1,26 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub.
export const metadata: Metadata = {
title: "랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 곧 공개됩니다.",
};
export default function LeaderboardPage() {
return (
<main className="shell">
<Hero back />
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
<p className="mt-3 text-[15px] font-bold"> </p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
,
100 .
</p>
</section>
<Footer />
</main>
);
}

View File

@ -0,0 +1,66 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
import Footer from "@/components/Footer";
import { GROUP_A } from "@/lib/schedule";
import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData";
// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성
export function generateStaticParams() {
return GROUP_A.map((m) => ({ matchId: m.matchId }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ matchId: string }>;
}): Promise<Metadata> {
const { matchId } = await params;
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) return { title: "TriplePick 2026" };
const title = `${match.teamA.shortName} vs ${match.teamB.shortName} AI 승부예측 | TriplePick`;
const desc = `GPT·Claude·Gemini가 ${match.teamA.name} vs ${match.teamB.name}를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요.`;
return {
title,
description: desc,
openGraph: { title, description: desc, siteName: "TriplePick", type: "website", locale: "ko_KR" },
twitter: { card: "summary_large_image", title, description: desc },
};
}
export default async function MatchPage({
params,
}: {
params: Promise<{ matchId: string }>;
}) {
const { matchId } = await params;
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) notFound();
const predictions = getPredictions(match);
const crowd = getCrowd(match);
return (
<main className="shell">
<Hero back />
{/* 경기별 후킹 카피 (한 줄) */}
<h1 className="mt-6 flex items-center justify-center gap-2 whitespace-nowrap text-[19px] font-extrabold leading-snug">
<span className="text-[var(--green)]"></span>
{match.hookText}
<span className="text-[var(--green)]"></span>
</h1>
<MatchupHUD match={match} />
<Arena
match={match}
predictions={predictions}
crowd={crowd}
shareUrl={matchUrl(match.matchId)}
/>
<Footer />
</main>
);
}

View File

@ -1,14 +1,29 @@
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
import ScheduleBoard from "@/components/ScheduleBoard";
import Footer from "@/components/Footer";
// L1. 전체 일정 대시보드 (랜딩 진입)
export default function Home() {
return (
<main className="shell">
<Hero />
<MatchupHUD />
<Arena />
{/* 기간 안내 + CTA 영역 */}
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
<p className="text-[14px] font-bold leading-snug">
GPT · Claude · Gemini
<br />
.
<br />
<span className="text-[var(--green)]">
,
<br />
<span className="whitespace-nowrap">100 </span>
</span>
</p>
</div>
<ScheduleBoard />
<Footer />
</main>
);

View File

@ -1,59 +1,75 @@
"use client";
import { useMemo, useState } from "react";
import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL, DEMO_FORCE_OPEN } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay } from "@/lib/format";
import type { Outcome, CrowdStats, ModelName } from "@/lib/types";
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
import type {
Outcome,
CrowdStats,
ModelName,
Match,
AIPrediction,
} from "@/lib/types";
import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag";
type Step = "pick" | "form" | "done";
const MODEL_COLOR: Record<ModelName, string> = {
GPT: "var(--gpt)",
Claude: "var(--claude)",
Gemini: "var(--gemini)",
};
const MODEL_ICON: Record<ModelName, string> = {
GPT: "/icons/gpt.png",
Claude: "/icons/claude.jpg",
Gemini: "/icons/gemini.jpeg",
};
export default function Arena() {
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function Arena({
match,
predictions,
crowd: initialCrowd,
shareUrl,
}: {
match: Match;
predictions: AIPrediction[];
crowd: CrowdStats;
shareUrl: string;
}) {
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [scoreA, setScoreA] = useState(2);
const [scoreB, setScoreB] = useState(1);
const [step, setStep] = useState<Step>("pick");
const [nickname, setNickname] = useState("");
const [email, setEmail] = useState("");
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(CROWD);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각)
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
const now = Date.now();
const notOpen = !DEMO_FORCE_OPEN && now < new Date(MATCH.opensAt).getTime();
const locked = now >= new Date(MATCH.lockAt).getTime();
const disabled = locked || notOpen; // 폼 비활성
const ctaLabel = notOpen
? `투표 오픈 ${kickoffDisplay(MATCH.opensAt)}`
: locked
? "예측 마감 (경기 시작)"
: "AI보다 잘 맞히기";
const finished = !!match.result;
const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime();
const locked = now >= new Date(match.lockAt).getTime();
const disabled = finished || locked || notOpen;
const ctaLabel = finished
? "결과 보기"
: notOpen
? `투표 오픈 ${kickoffDisplay(match.opensAt)}`
: locked
? "예측 마감 (경기 시작)"
: "AI보다 잘 맞히기";
const matched = useMemo(() => {
if (!outcome) return [];
return AI_PREDICTIONS.filter((p) => p.outcome === outcome).map((p) => ({
model: p.model,
exact: p.scoreA === scoreA && p.scoreB === scoreB,
}));
}, [outcome, scoreA, scoreB]);
return predictions
.filter((p) => p.outcome === outcome)
.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 (nickname.trim().length < 2) return;
if (!EMAIL_RE.test(email.trim())) return;
setCrowd((c) => ({
...c,
total: c.total + 1,
@ -66,16 +82,16 @@ export default function Arena() {
const shareText = useMemo(() => {
if (!outcome) return "";
const me = `${MATCH.teamA.shortName} ${scoreA}-${scoreB} ${MATCH.teamB.shortName}`;
const me = `${match.teamA.shortName} ${scoreA}-${scoreB} ${match.teamB.shortName}`;
const sameAI = matched.map((m) => m.model).join("·");
return sameAI
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
}, [outcome, scoreA, scoreB, matched]);
}, [outcome, scoreA, scoreB, matched, match]);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(`${shareText}\n${LANDING_URL}`);
await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
@ -85,7 +101,11 @@ export default function Arena() {
const nativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({ text: shareText, url: LANDING_URL });
await navigator.share({
title: `${match.teamA.shortName} vs ${match.teamB.shortName} — TriplePick`,
text: shareText,
url: shareUrl,
});
} catch {
/* cancelled */
}
@ -94,57 +114,59 @@ export default function Arena() {
return (
<>
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
{!finished && !notOpen && (
<div className="mt-4">
<Countdown to={match.lockAt} />
</div>
)}
{/* ===== 레이어드 화이트 시트 (002) ===== */}
<section className="sheet mt-6 p-5 text-[var(--ink)]">
<div className="mb-3 flex items-end gap-2">
<h2 className="text-[22px] font-extrabold">AI </h2>
<span className="mb-1.5 h-[4px] w-10 rounded bg-[var(--green)]" />
<section className="sheet mt-4 p-5 text-[var(--ink)]">
<div className="mb-3">
<h2 className="text-[22px] font-extrabold">AI </h2>
</div>
<div className="flex flex-col gap-3">
{AI_PREDICTIONS.map((p) => (
<div
key={p.model}
className="rounded-2xl border border-[var(--line-l)] bg-[var(--sheet-card)] p-4"
>
<div className="flex items-center gap-3">
<img
src={MODEL_ICON[p.model]}
alt={p.model}
className="h-12 w-12 shrink-0 rounded-full object-cover"
/>
<div className="min-w-0">
<div className="text-[20px] font-extrabold leading-none">
{p.model}
{predictions.map((p) => {
const wp = winProb(match, p);
return (
<div
key={p.model}
className="rounded-2xl border border-[var(--line-l)] bg-[var(--sheet-card)] p-4"
>
<div className="flex items-center gap-3">
<img
src={MODEL_ICON[p.model]}
alt={p.model}
className="h-12 w-12 shrink-0 rounded-full object-cover"
/>
<div className="min-w-0">
<div className="text-[20px] font-extrabold leading-none">
{p.model}
</div>
{/* D3: 승리 예측 팀의 승리 확률 + 승리팀 국기 */}
<div className="mt-1.5 flex items-center gap-1.5">
{wp.team && (
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
)}
<span className="text-[12px] font-bold text-[var(--ink-muted)]">
{wp.label} <span className="text-[var(--gpt)]">{wp.pct}%</span>
</span>
</div>
</div>
<div className="mt-1 text-[11px] font-semibold text-[var(--ink-muted)]">
Confidence
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-3">
<div className="whitespace-nowrap font-mono text-[28px] font-extrabold tabular-nums">
<div className="ml-auto shrink-0 whitespace-nowrap font-mono text-[28px] font-extrabold tabular-nums">
{p.scoreA}&nbsp;-&nbsp;{p.scoreB}
</div>
<div className="w-11 text-right text-[18px] font-extrabold">
{p.confidencePct}%
</div>
</div>
</div>
<div className="mt-2.5 h-2.5 w-full overflow-hidden rounded-full bg-[#eceee9]">
<div
className="bar-anim h-full rounded-full"
style={{
width: `${p.confidencePct}%`,
background: MODEL_COLOR[p.model],
}}
/>
{/* 메인 리즌 강조 (D3: 확률은 텍스트, 바 제거) */}
<p className="mt-3 text-[15px] font-bold leading-snug text-[var(--ink)]">
{p.reasonShort}
</p>
</div>
<p className="mt-2 text-[13px] leading-snug text-[var(--ink-muted)]">
{p.reasonShort}
</p>
</div>
))}
);
})}
</div>
{/* 당신의 선택 */}
@ -158,16 +180,16 @@ export default function Arena() {
<div className="mt-3 grid grid-cols-3 gap-2">
{(
[
["TEAM_A_WIN", `${MATCH.teamA.shortName}`],
["TEAM_A_WIN", `${match.teamA.shortName}`],
["DRAW", "무승부"],
["TEAM_B_WIN", `${MATCH.teamB.shortName}`],
["TEAM_B_WIN", `${match.teamB.shortName}`],
] as [Outcome, string][]
).map(([val, label]) => (
<button
key={val}
disabled={disabled}
onClick={() => setOutcome(val)}
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition ${
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition disabled:opacity-50 ${
outcome === val
? "border-transparent bg-[var(--mint)] text-[var(--mint-ink)]"
: "border-[var(--line-l)] bg-white text-[var(--ink-muted)]"
@ -179,14 +201,41 @@ export default function Arena() {
</div>
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
<Stepper label={MATCH.teamA.shortName} value={scoreA} onChange={setScoreA} disabled={disabled} />
<Stepper label={match.teamA.shortName} value={scoreA} onChange={setScoreA} disabled={disabled} />
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
<Stepper label={MATCH.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={disabled} />
<Stepper label={match.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={disabled} />
</div>
</section>
{/* ===== CTA 민트 #85FCA8 ===== */}
{step === "pick" && (
{/* ===== 종료된 경기: 결과 보기 ===== */}
{finished && match.result && (
<section className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
<div className="text-[13px] text-[var(--ink-muted)]"> </div>
<div className="mt-1 text-[26px] font-extrabold">
{match.teamA.shortName} {match.result.scoreA}-{match.result.scoreB}{" "}
{match.teamB.shortName}
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
{outcomeLabel(match, match.result.outcome)}
</span>
</div>
<div className="mt-3 flex flex-col gap-1.5">
{predictions.map((p) => {
const hit = p.outcome === match.result!.outcome;
return (
<div key={p.model} className="flex items-center justify-between text-[13px]">
<span className="font-bold text-white/85">{p.model}</span>
<span className={hit ? "font-bold text-[var(--green)]" : "text-white/45"}>
{hit ? "적중 ✓" : "빗나감"}
</span>
</div>
);
})}
</div>
</section>
)}
{/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */}
{!finished && step === "pick" && (
<button
onClick={submitPick}
disabled={!outcome || disabled}
@ -196,28 +245,29 @@ export default function Arena() {
</button>
)}
{step === "form" && (
{/* ===== 폼: 이메일만 (D5, 닉네임 제거) ===== */}
{!finished && step === "form" && (
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<input
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={16}
placeholder="닉네임 (2~16자)"
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)]"
/>
<input
type="email"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일 (선택 · 결과 알림)"
placeholder="이메일 (결과·다음 경기 알림)"
className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-3 text-[15px] text-white outline-none focus:border-[var(--green)]"
/>
<label className="flex items-center gap-2 text-[13px] text-[var(--ink-muted)]">
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} className="accent-[var(--mint)]" />
(AI )
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
<input
type="checkbox"
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
className="mt-0.5 accent-[var(--mint)]"
/>
. ·100 .
</label>
<button
onClick={confirmSubmit}
disabled={nickname.trim().length < 2}
disabled={!EMAIL_RE.test(email.trim())}
className="btn-mint w-full rounded-xl py-3.5 text-[17px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
>
AI
@ -225,20 +275,21 @@ export default function Arena() {
</div>
)}
{step === "done" && outcome && (
{/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */}
{!finished && step === "done" && outcome && (
<div className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
<div className="text-[13px] text-[var(--ink-muted)]"> </div>
<div className="mt-1 text-[26px] font-extrabold">
{MATCH.teamA.shortName} {scoreA}-{scoreB} {MATCH.teamB.shortName}
{match.teamA.shortName} {scoreA}-{scoreB} {match.teamB.shortName}
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
{outcomeLabel(MATCH, outcome)}
{outcomeLabel(match, outcome)}
</span>
</div>
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
{matched.length > 0 ? (
<>
<b>{matched.map((m) => m.model).join("·")}</b>
.{matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "}
<b>{matched.map((m) => m.model).join("·")}</b> .
{matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "}
AI .
</>
) : (
@ -247,7 +298,7 @@ export default function Arena() {
</p>
<div className="mt-4 grid grid-cols-2 gap-2.5">
<button onClick={nativeShare} className="btn-mint rounded-xl py-3 text-[15px] font-bold active:scale-[0.99]">
</button>
<button onClick={copyLink} className="rounded-xl border border-[var(--line-d)] py-3 text-[15px] font-bold active:scale-[0.99]">
{copied ? "복사됨 ✓" : "링크 복사"}
@ -265,8 +316,7 @@ export default function Arena() {
<span></span>
</div>
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
<b className="text-[var(--gold-border)]">NEW</b>
100
· , 1 100
</p>
</div>
<button className="gold-btn shrink-0 rounded-xl px-4 py-3.5 text-[14px] font-extrabold leading-tight active:scale-[0.98]">
@ -277,18 +327,19 @@ export default function Arena() {
</div>
</section>
{/* ===== Crowd Pick (003) ===== */}
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
<section className="mt-6">
<div className="mb-2.5 text-[20px] font-extrabold">
<div className="mb-1 text-[20px] font-extrabold">
Crowd Pick{" "}
<span className="text-[14px] text-[var(--ink-muted)]">
|
</span>
<span className="text-[14px] text-[var(--ink-muted)]">| </span>
</div>
<div className="mb-2.5 text-[11px] text-[var(--ink-muted)]">
(AI )
</div>
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
<CrowdSeg flag="/icons/kor.png" value={pct(crowd.teamAWin, crowd.total)} tone="kor" />
<CrowdSeg team={match.teamA} value={pct(crowd.teamAWin, crowd.total)} tone="a" />
<CrowdSeg value={pct(crowd.draw, crowd.total)} tone="draw" />
<CrowdSeg cze value={pct(crowd.teamBWin, crowd.total)} tone="cze" />
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, crowd.total)} tone="b" />
</div>
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
{crowd.total.toLocaleString()}
@ -348,31 +399,21 @@ function StepBtn({
}
function CrowdSeg({
flag,
cze,
team,
value,
tone,
}: {
flag?: string;
cze?: boolean;
team?: import("@/lib/types").Team;
value: number;
tone: "kor" | "draw" | "cze";
tone: "a" | "draw" | "b";
}) {
const bg =
tone === "draw" ? "#2b313c" : tone === "kor" ? "rgba(62,224,138,0.22)" : "#222831";
const bg = tone === "draw" ? "#2b313c" : tone === "a" ? "rgba(74,255,160,0.22)" : "#222831";
return (
<div
className="flex items-center justify-center gap-1.5 border-r border-[var(--line-d)] text-[16px] font-extrabold last:border-r-0"
style={{ width: `${value}%`, background: bg, minWidth: 56 }}
>
{flag && <img src={flag} alt="KOR" className="h-4 w-6 rounded object-cover" />}
{cze && (
<svg viewBox="0 0 6 4" className="h-4 w-6 rounded" preserveAspectRatio="none">
<rect width="6" height="2" y="0" fill="#fff" />
<rect width="6" height="2" y="2" fill="#d7141a" />
<polygon points="0,0 3,2 0,4" fill="#11457e" />
</svg>
)}
{team && <TeamFlag team={team} className="h-4 w-6" />}
<span>{value}%</span>
</div>
);

View File

@ -5,7 +5,7 @@ export default function BallIcon({ className = "" }: { className?: string }) {
src="/icons/profile.png?v=3"
alt="TriplePick"
className={`rounded-full ${className}`}
style={{ filter: "drop-shadow(0 0 7px rgba(62,224,138,0.55))" }}
style={{ filter: "drop-shadow(0 0 7px rgba(74,255,160,0.55))" }}
/>
);
}

53
components/Countdown.tsx Normal file
View File

@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
export default function Countdown({
to,
label = "투표 종료까지",
}: {
to: string;
label?: string;
}) {
const [left, setLeft] = useState<number | null>(null);
useEffect(() => {
const target = new Date(to).getTime();
const tick = () => setLeft(Math.max(0, target - Date.now()));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [to]);
const ended = left !== null && left <= 0;
return (
<div className="flex items-center justify-center gap-2 rounded-xl border border-[var(--line-d)] bg-[var(--bg2)] px-3 py-2.5">
<span className="text-[12px] font-semibold text-[var(--ink-muted)]">
{ended ? "투표가 마감되었습니다" : label}
</span>
{!ended && (
<span
className="font-mono text-[18px] font-extrabold tabular-nums text-[var(--green)]"
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
suppressHydrationWarning
>
{left === null ? "--:--:--" : fmt(left)}
</span>
)}
</div>
);
}
function fmt(ms: number): string {
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (d > 0) return `${d}${pad(h)}:${pad(m)}:${pad(sec)}`;
return `${pad(h)}:${pad(m)}:${pad(sec)}`;
}

View File

@ -1,15 +1,15 @@
export default function Footer() {
return (
<footer className="mt-6 text-center">
<p className="text-[10.5px] font-semibold text-[var(--ink-muted)]">
<p className="text-[10.5px] font-semibold text-white/75">
Independent AI prediction event. Not official.
</p>
<p className="mt-1 text-[10.5px] leading-relaxed text-[var(--ink-muted)]">
<p className="mt-1 text-[10.5px] leading-relaxed text-white/60">
AI , ···
.
</p>
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-[var(--ink-muted)]/70">
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-white/55">
<p>
· ·
. AI .
@ -20,7 +20,7 @@ export default function Footer() {
</p>
</div>
<div className="mt-3 text-[10px] text-[var(--ink-muted)]">
<div className="mt-3 text-[10px] text-white/70">
© 2026 TriplePick · AIO2O · @triplepick_ai
</div>
</footer>

View File

@ -1,8 +1,19 @@
import { MATCH } from "@/lib/mockData";
import Link from "next/link";
export default function Hero() {
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
export default function Hero({ back = false }: { back?: boolean }) {
return (
<header className="pt-7 text-center">
<header className="pt-5 text-center">
{back && (
<div className="mb-3 flex">
<Link
href="/"
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"
>
</Link>
</div>
)}
<div className="font-impact text-[44px] leading-none tracking-tight">
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
</div>
@ -10,16 +21,7 @@ export default function Hero() {
AI Prediction Arena
</div>
<div className="mt-2 inline-block rounded-full border border-white/12 bg-white/8 px-3 py-1 text-[13px] font-semibold text-white/85">
AI
</div>
<h1 className="mt-6 flex items-center justify-center gap-2 whitespace-nowrap text-[19px] font-extrabold leading-snug">
<span className="text-[var(--green)]"></span>
{MATCH.hookText}
<span className="text-[var(--green)]"></span>
</h1>
<div className="mt-1.5 text-[13px] font-semibold text-[var(--green)]">
Korea Republic vs Czechia · Matchday Prediction
AI
</div>
</header>
);

View File

@ -1,12 +1,14 @@
import { MATCH } from "@/lib/mockData";
import type { Match } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format";
import BallIcon from "./BallIcon";
import TeamFlag from "./TeamFlag";
export default function MatchupHUD() {
const { group, roundLabel } = MATCH;
export default function MatchupHUD({ match }: { match: Match }) {
const { group, roundLabel } = match;
const finished = !!match.result;
return (
<section className="mt-6">
{/* Match 01 → Final 진행 */}
{/* 진행 바: roundLabel → Final */}
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold">
<span className="rounded-md border border-[var(--green)] px-2.5 py-1 text-[var(--green)]">
{roundLabel}
@ -27,65 +29,52 @@ export default function MatchupHUD() {
{/* 대결 카드 (녹색 글로우 보더) */}
<div
className="rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5"
style={{ boxShadow: "0 0 28px rgba(62,224,138,0.35), inset 0 0 24px rgba(62,224,138,0.06)" }}
style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }}
>
{/* 헤더: 축구공 아이콘 + 타이틀 + 일시 */}
{/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
<div className="flex items-center gap-3">
<BallIcon className="h-14 w-14 shrink-0" />
<div className="min-w-0">
<div className="text-[22px] font-extrabold leading-tight">
{MATCH.teamA.name}{" "}
<span className="text-white/55">vs</span> {MATCH.teamB.name}
{match.teamA.name} <span className="text-white/55">vs</span>{" "}
{match.teamB.name}
</div>
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
{kickoffDisplay(MATCH.kickoffKst)} · Group {group}
{kickoffDisplay(match.kickoffKst)} · Group {group}
</div>
<div className="mt-0.5 text-[12px] text-white/65">{MATCH.venue}</div>
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div>
</div>
{/* 국기 + 라이트닝 VS */}
{/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */}
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<KorFlag />
<TeamFlag team={match.teamA} className="mx-auto h-[68px] w-[104px]" />
<div className="relative grid place-items-center">
<div className="vs-glow absolute h-28 w-28" />
<span
className="relative font-impact text-[46px] italic leading-none text-[var(--green)]"
style={{ textShadow: "0 0 18px rgba(62,224,138,0.8)" }}
>
VS
</span>
{finished && match.result ? (
<span className="relative whitespace-nowrap font-mono text-[34px] font-extrabold tabular-nums text-white">
{match.result.scoreA}
<span className="px-1 text-white/55">-</span>
{match.result.scoreB}
</span>
) : (
<span
className="relative font-impact text-[46px] italic leading-none text-[var(--green)]"
style={{ textShadow: "0 0 18px rgba(74,255,160,0.8)" }}
>
VS
</span>
)}
</div>
<CzeFlag />
<TeamFlag team={match.teamB} className="mx-auto h-[68px] w-[104px]" />
</div>
{finished && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
·
</div>
)}
</div>
</section>
);
}
function KorFlag() {
// 실제 국기 이미지
return (
<img
src="/icons/kor.png"
alt="Korea Republic"
className="flag-img mx-auto h-[68px] w-[104px]"
/>
);
}
function CzeFlag() {
// 체코 국기 (이미지 없음 → SVG)
return (
<svg
viewBox="0 0 6 4"
className="mx-auto h-[68px] w-[104px] rounded-lg border border-[var(--line-d)]"
preserveAspectRatio="none"
aria-label="Czechia"
>
<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>
);
}

View File

@ -0,0 +1,98 @@
import Link from "next/link";
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
import { getPredictions, getCrowd } from "@/lib/mockData";
import { dateHeading, timeOnly } from "@/lib/format";
import type { Match } from "@/lib/types";
import TeamFlag from "./TeamFlag";
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
const PHASE_META: Record<MatchPhase, { label: string; cls: string }> = {
open: { label: "투표 중", cls: "border-[#94FBE0] text-[#94FBE0]" },
scheduled: { label: "오픈 예정", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" },
locked: { label: "투표 마감", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" },
finished: { label: "종료", cls: "border-white/15 text-white/55" },
};
export default function ScheduleBoard() {
const groups = matchesByDate();
return (
<section className="mt-6">
<div className="mb-3">
<h2 className="text-[22px] font-extrabold"> </h2>
</div>
<div className="flex flex-col gap-5">
{groups.map((g) => (
<div key={g.date}>
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
{dateHeading(g.date)}
</div>
<div className="flex flex-col gap-2.5">
{g.matches.map((m) => (
<MatchCard key={m.matchId} match={m} />
))}
</div>
</div>
))}
</div>
</section>
);
}
function MatchCard({ match }: { match: Match }) {
const phase = matchPhase(match);
const meta = PHASE_META[phase];
const preds = getPredictions(match);
const crowd = getCrowd(match);
// AI 픽 갈림 요약
const tally = { a: 0, d: 0, b: 0 };
for (const p of preds) {
if (p.outcome === "TEAM_A_WIN") tally.a++;
else if (p.outcome === "DRAW") tally.d++;
else tally.b++;
}
const split: string[] = [];
if (tally.a) split.push(`${match.teamA.shortName} ${tally.a}`);
if (tally.d) split.push(`${tally.d}`);
if (tally.b) split.push(`${match.teamB.shortName} ${tally.b}`);
return (
<Link
href={`/match/${match.matchId}`}
className="block rounded-2xl border-2 border-[#94FBE0]/45 bg-[#171b21] p-4 transition active:scale-[0.99] hover:border-[#94FBE0]"
>
<div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70">
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> · {match.roundLabel}
</span>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${meta.cls}`}>
{meta.label}
</span>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div className="flex items-center gap-2">
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
<span className="truncate text-[15px] font-extrabold">{match.teamA.shortName}</span>
</div>
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
<div className="flex items-center justify-end gap-2">
<span className="truncate text-right text-[15px] font-extrabold">{match.teamB.shortName}</span>
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
<span>
<span className="font-semibold text-white/55">AI </span> {split.join(" · ")}
</span>
<span className="flex items-center gap-2">
<span>{crowd.total.toLocaleString()} </span>
<span className="text-[#94FBE0]"></span>
</span>
</div>
</Link>
);
}

46
components/TeamFlag.tsx Normal file
View File

@ -0,0 +1,46 @@
import type { Team } from "@/lib/types";
// 모든 경기에 일반화된 국기 렌더.
// KOR = 실제 png 자산, CZE = 인라인 SVG(기존 디자인 유지), 그 외 = 이모지 박스.
export default function TeamFlag({
team,
className = "",
}: {
team: Team;
className?: string;
}) {
if (team.code === "KOR") {
return (
<img
src="/icons/kor.png"
alt={team.name}
className={`flag-img object-cover ${className}`}
/>
);
}
if (team.code === "CZE") {
return (
<svg
viewBox="0 0 6 4"
className={`rounded-lg border border-[var(--line-d)] ${className}`}
preserveAspectRatio="none"
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>
);
}
// 그 외 국가 — 이모지 국기 (자산 추가 전 fallback)
return (
<span
className={`grid place-items-center overflow-hidden rounded-lg border border-[var(--line-d)] bg-white/[0.06] ${className}`}
aria-label={team.name}
>
<span className="leading-none" style={{ fontSize: "1.7em" }}>
{team.flag}
</span>
</span>
);
}

View File

@ -89,12 +89,22 @@
---
## 8. 06.09 회의로 반영해야 할 디자인 변경 (대기)
## 8. 06.09 회의 반영 (DONE 2026-06-09)
> 상세: `../../Worldcup 2026/000. Plan/Task — 랜딩·디자인·프론트엔드 업데이트 (06.09 회의).md`
- [ ] 확률 라벨 `Confidence`**"OO 승리 확률 XX%"**(이긴 팀 기준) + 승리팀 국기
- [ ] confidence 바 축소/삭제 검토, **근거(메인 리즌) 강조**, 모델 텍스트 짧고 재미있게
- [ ] **닉네임 필드 제거 → 이메일만**
- [ ] 서브타이틀 `Korea Republic vs Czechia · Matchday Prediction` **삭제(중복)**
- [ ] **"투표 종료까지" 카운트다운** 상세 페이지에 복원
- [ ] 멀티 경기: 대시보드 + 경기 상세 + (차기)리더보드 — 동일 토큰으로 확장
- [ ] 예측하기 → (경기 후) **결과 보기** 버튼 + 최종 스코어 표시
- [x] 확률 라벨 `Confidence`**"OO 승리 확률 XX%"**(이긴 팀 기준) + 승리팀 국기 (`format.winProb` + `TeamFlag`)
- [x] confidence 바 **h-1로 축소**, **근거(메인 리즌) 15px 볼드 인용("…")으로 강조**
- [x] **닉네임 필드 제거 → 이메일만** + 동의 체크박스(다음 경기/100만원)
- [x] 서브타이틀 `… Matchday Prediction` **삭제(중복)** — Hero는 브랜드 헤더로 단일화
- [x] **"투표 종료까지" 카운트다운** 상세에 복원 (`Countdown.tsx`, 마감=lockAt=킥오프)
- [x] 멀티 경기: **대시보드(`/`) + 경기 상세(`/match/[matchId]`) + 리더보드 stub(`/leaderboard`)** — 동일 토큰
- [x] 예측하기 → (경기 후) **결과 보기** + 최종 스코어 (`match.result` 분기, MatchupHUD/Arena)
- [x] **"월드컵" 표현 제거**(D9) — Hero·schedule hookText·meta → "글로벌 축구"
- [x] **경기별 공유**(D8) — `matchUrl()` 딥링크, `navigator.share` invoke
- [x] Crowd Pick에 **"AI 승리 확률과 다릅니다"** 구분 라벨
## 9. 멀티 경기 구조 (06.09 신규)
- **라우팅**: `/`=대시보드(전체 일정) · `/match/[matchId]`=경기 상세(6경기 SSG) · `/leaderboard`=차기 stub
- **컴포넌트 파라미터화**: `MatchupHUD({match})` · `Arena({match, predictions, crowd, shareUrl})` — 싱글톤 의존 제거
- **국기 일반화**: `TeamFlag({team})` — KOR png / CZE 인라인 SVG / 그 외 이모지 박스
- **데이터**: `mockData.getPredictions(match)`/`getCrowd(match)` — 체코전 큐레이션, 그 외 `matchId` 해시 결정론 생성(Math.random 미사용)
- **대시보드 카드**(`ScheduleBoard.tsx`): 날짜 그룹 → 시간·라운드·상태칩·팀 국기·AI 픽 갈림·참여수 → 상세 딥링크. 네이버 e스포츠 승부예측 톤, 다크 토큰 유지

165
docs/SCORING.md Normal file
View File

@ -0,0 +1,165 @@
# TriplePick — 참여자 채점 · 심사 설계 (SSOT)
최종 갱신: 2026-06-09 KST
근거: `001. Meeting Minute/WorldCup_triplepick landing_2026.06.09.txt` (05:48 승패/스코어 가점, 30:36~31:37 포인트 총점·주최자 공정성, 51:56 근접/정확 배점) + 대표 확정(2026-06-09).
관련: `docs/BACKEND.md`(채점 구현), `lib/types.ts`(타입), 골드 상금 카드(`components/Arena.tsx`).
> 이 문서는 **100만원 최종 우승자 심사의 단일 기준(SSOT)**이다. 약관·랜딩 고지·백엔드 채점은 모두 이 문서를 따른다.
---
## 0. 확정 사항 (한 줄 요약)
- **공정성 모델 = 주최측 배제 + 순수 누적 포인트.** 점수는 **정확도에서만** 발생(참여 횟수에 직접 점수 없음).
- 100만원 = **대회 통합 누적 포인트 1위 1인**에게 최종 지급(경기마다 X).
- 채점은 **내 예측 vs 실제 경기 결과**. AI(GPT·Claude·Gemini)는 재미·기준점이며 채점 대상이 아니다.
---
## 1. 공정성 3원칙
| # | 원칙 | 이유 |
|---|---|---|
| **P1. 주최측·임직원 배제** | 운영진/주최자는 수상 대상에서 제외 | 회의 30:36 우려("주최자가 모든 경기 참여 가능 → 유리") 직접 해소. 상금 이벤트 표준 약관 |
| **P2. 점수는 정확도에서만** | 참여 횟수 자체에는 점수를 주지 않음 (틀린 픽 = 0점) | "많이 찍기" 어뷰징 무력화 — 부정확한 대량 참여는 보상이 0 |
| **P3. 누적 총점 1위** | 대회 전체 누적 포인트가 가장 높은 사람 | 대회 끝에 지급 → 1경기 운빨 우승은 누적으로 자연 추월됨. 꾸준한 정확도가 이김 |
> **누적이 운빨을 방어하는 이유:** 단일 경기 최대 5점. 토너먼트(다수 경기) 누적에서는 성실·정확한 참여자가 반드시 앞선다. 그래서 별도 "최소 참여 경기 자격선"은 두지 않는다(불필요).
---
## 2. 경기별 적중 포인트 (정확도 기반)
예측 `pick = {scoreA, scoreB, outcome}` vs 실제 `result = {scoreA, scoreB, outcome}`.
| 등급 | 조건 | 포인트 (기본값·**팀 확정 대상**) |
|---|---|---|
| **정확 스코어** | 양 팀 득점 **완전 일치** (승패 당연 포함) | **5** |
| **근접 (득실차)** | 승패 적중 **+ 득실차 일치** (정확 스코어 제외) | **3** |
| **승패 적중** | 승/무/패만 일치 | **2** |
| **부분 근접** | 승패는 빗나갔으나 **한 팀 득점 일치** | **1** |
| **빗나감** | 그 외 | **0** |
- 단조 증가(정확 > 근접 > 승패 > 부분 > 빗나감)라 "더 정밀할수록 더 많은 점수"가 보장된다.
- 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 **근접(3점)**.
- **배점 수치는 황 본부장 확정 후 dev 전달**(회의 05:48). 위는 기본 제안값.
### 채점 알고리즘 (결정론적)
```
1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB → 정확(5)
2) else if pick.outcome == result.outcome:
(pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2)
3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1)
4) else → 0
```
---
## 3. 랭킹 (누적 총점 1위)
- **1차 정렬: 누적 적중 포인트 합계(Σ)** — 높을수록 상위.
- **동점자 보정(tie-break, 순서대로):**
1. 정확 스코어 적중 **횟수** (정밀성)
2. **적중률** = 누적 포인트 ÷ 참여 경기 수 (효율)
3. 참여 경기 수 (성실성)
4. 최초 해당 점수 도달 **시각** (먼저 달성한 쪽)
- 동점 보정도 모두 같으면 **약관에 따라 분할 또는 추첨**.
> 참여 경기 수·적중률은 **동점 보정용**으로만 쓴다. 1차 순위는 어디까지나 순수 누적 포인트.
---
## 4. 자격 · 배제 (P1 구현)
- **식별 단위 = 이메일**(비로그인). 정규화: 소문자화, (옵션) Gmail `.`/`+alias` 정규화로 동일인 합산.
- **배제 대상:**
- 임직원·운영진 이메일 **블록리스트**(`STAFF_EMAILS`).
- 회사 도메인 자동 제외: `@o2o.kr`, `@aio2o.*` 등(`STAFF_DOMAINS`).
- **알려진 한계 — 다중 이메일 어뷰징:** 한 사람이 여러 메일로 참여 가능. 현 단계 비로그인 정책상 완전 차단 불가 → **약관에 "1인 1계정 원칙, 위반 시 실격" 명시** + 차기 본인 인증(휴대폰/소셜)으로 보완. 의심 계정은 운영자 수동 실격 가능.
---
## 5. 채점 대상 "결과"의 정의 (분쟁 방지)
> ★ **팀 확인 필요** — 아래는 권장 기본값.
- **정규시간(전·후반 + 추가시간) 종료 스코어** 기준. **연장·승부차기 제외**(예측은 90분 기준이 일반적).
- 경기 **취소/몰수/중단**: 해당 경기 **전원 채점 제외**(자격 분모에서도 제외).
- **무효 제출:** 마감(킥오프) 이후 제출 무효. 동일 이메일 다중 제출 시 **마감 전 마지막 1건만 유효**.
- 결과 입력 출처(공식 스코어 소스)와 정정 정책은 `BACKEND.md`에서 1개 소스로 고정.
---
## 6. 투명성 (회의 30:38 "오해 없이 사전 공지")
발행 전 **랜딩/약관에 사전 고지**:
- 배점표(2장) · 배제 대상(4장) · 동점 규칙(3장) · 결과 기준=정규시간(5장) · 지급 방식(대회 통합 1인).
- 본인 누적 포인트·현재 순위 열람(리더보드 = 차기 페이지에서).
---
## 7. 참조 구현 (TypeScript, `lib/types.ts` 정합)
```ts
import type { Outcome, UserPick } from "@/lib/types";
type MatchResult = { scoreA: number; scoreB: number; outcome: Outcome };
// 배점 — 팀 확정 후 이 상수만 교체
export const SCORE = { EXACT: 5, CLOSE: 3, OUTCOME: 2, PARTIAL: 1, MISS: 0 } as const;
export function scorePrediction(pick: UserPick, r: MatchResult): number {
if (pick.scoreA === r.scoreA && pick.scoreB === r.scoreB) return SCORE.EXACT;
if (pick.outcome === r.outcome) {
return pick.scoreA - pick.scoreB === r.scoreA - r.scoreB ? SCORE.CLOSE : SCORE.OUTCOME;
}
if (pick.scoreA === r.scoreA || pick.scoreB === r.scoreB) return SCORE.PARTIAL;
return SCORE.MISS;
}
// 배제 대상
const STAFF_EMAILS = new Set<string>([/* "ceo@…", … */]);
const STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"];
export function isExcluded(email: string): boolean {
const e = email.trim().toLowerCase();
return STAFF_EMAILS.has(e) || STAFF_DOMAINS.some((d) => e.endsWith("@" + d));
}
// 누적 집계 → 랭킹
export interface Standing {
email: string;
totalPoints: number; // 1차 정렬
exactCount: number; // tie ①
matchesPlayed: number; // tie ③ (적중률 분모)
firstReachedAt: number;// tie ④ (epoch ms)
}
export function rank(standings: Standing[]): Standing[] {
return standings
.filter((s) => !isExcluded(s.email))
.sort((a, b) =>
b.totalPoints - a.totalPoints ||
b.exactCount - a.exactCount ||
b.totalPoints / b.matchesPlayed - a.totalPoints / a.matchesPlayed || // 적중률 tie②
b.matchesPlayed - a.matchesPlayed ||
a.firstReachedAt - b.firstReachedAt,
);
}
```
> `UserPick``lib/types.ts`에 이미 정의됨(`{outcome, scoreA, scoreB}`). 위 함수는 백엔드(Cloud Functions) 결과 입력 시 호출되어 사용자별 누적 포인트를 갱신한다(상세 `BACKEND.md`).
---
## 8. 미결정 / 팀 확정 대기
1. **배점 수치** — `SCORE` 5/3/2/1/0 기본값 확정 (황 본부장, 회의 05:48). 부분근접(1점) 채택 여부 포함.
2. **결과 기준** — 정규시간 90분 vs 연장 포함 (5장). 권장=정규시간.
3. **공식 스코어 소스** — 결과 입력 단일 출처 + 정정 정책(`BACKEND.md`).
4. **동률 최종 처리** — 분할 vs 추첨 (약관).
5. **랜딩 카피 정합** — 골드 상금 카드 문구를 본 모델에 맞춤(아래).
### 골드 카드 카피 정합 (권장)
현재: "투표 참여율, 승률과 점수 정답률 점수로 최종 1등에게 증정"
→ 본 모델(참여 횟수는 점수 아님)과 맞추려면:
**"경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원"** 권장.

View File

@ -1,4 +1,4 @@
import type { Outcome, Match } from "./types";
import type { Outcome, Match, AIPrediction, Team } from "./types";
export function outcomeLabel(match: Match, o: Outcome): string {
if (o === "TEAM_A_WIN") return `${match.teamA.shortName}`;
@ -6,6 +6,40 @@ export function outcomeLabel(match: Match, o: Outcome): string {
return "무승부";
}
// D3: 확률 = "이긴다고 예측한 팀의 승리 확률". 무승부 예측이면 "무승부 가능성".
export function winProb(
match: Match,
p: Pick<AIPrediction, "outcome" | "confidencePct">,
): { team: Team | null; label: string; pct: number } {
if (p.outcome === "TEAM_A_WIN")
return { team: match.teamA, label: `${match.teamA.shortName} 승리 확률`, pct: p.confidencePct };
if (p.outcome === "TEAM_B_WIN")
return { team: match.teamB, label: `${match.teamB.shortName} 승리 확률`, pct: p.confidencePct };
return { team: null, label: "무승부 가능성", pct: p.confidencePct };
}
// "6월 12일 (금)" — 대시보드 날짜 그룹 헤더
export function dateHeading(iso: string): string {
const days = ["일", "월", "화", "수", "목", "금", "토"];
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
if (!dm) return iso;
const [, y, mo, d] = dm;
const dow = days[new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay()];
return `${+mo}${+d}일 (${dow})`;
}
// "11:00" — 경기 카드 시간
export function timeOnly(iso: string): string {
const m = iso.match(/T(\d{2}):(\d{2})/);
return m ? `${m[1]}:${m[2]}` : "";
}
// "2026-06-12" — 날짜 그룹 키
export function dateKey(iso: string): string {
const m = iso.match(/(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : iso;
}
export function kickoffDisplay(iso: string): string {
// iso는 KST(+09:00). 런타임 TZ에 무관하게 ISO 문자열을 직접 파싱한다.
const days = ["일", "월", "화", "수", "목", "금", "토"];

View File

@ -1,8 +1,11 @@
// 1차 mock 데이터 — Firebase 연결 전까지 사용.
// ⚠️ AI 예측은 플레이스홀더. 발행 전 GPT/Claude/Gemini 실제 출력(JSON)으로 교체할 것.
// 경기 일정은 lib/schedule.ts(Group A 정식 일정, KST)에서 자동 반영.
import type { AIPrediction, CrowdStats } from "./types";
import { FEATURED } from "./schedule";
//
// 멀티 경기(06.09 회의): 모든 경기에 3 AI 예측 + Crowd 가 있어야 대시보드/상세가 깨지지 않는다.
// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용).
import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types";
import { FEATURED, GROUP_A } from "./schedule";
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
@ -10,7 +13,8 @@ export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSO
// true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로.
export const DEMO_FORCE_OPEN = true;
export const AI_PREDICTIONS: AIPrediction[] = [
// 체코전 큐레이션 예측 (D3: reason 짧고 또렷이)
const FEATURED_PREDICTIONS: AIPrediction[] = [
{
matchId: MATCH.matchId,
model: "GPT",
@ -18,7 +22,7 @@ export const AI_PREDICTIONS: AIPrediction[] = [
scoreA: 2,
scoreB: 1,
confidencePct: 62,
reasonShort: "한국의 측면 공격 우위와 세트피스 강점",
reasonShort: "한국 측면 스피드가 한 끗 위",
generatedAt: "2026-06-08",
},
{
@ -28,7 +32,7 @@ export const AI_PREDICTIONS: AIPrediction[] = [
scoreA: 1,
scoreB: 1,
confidencePct: 41,
reasonShort: "중원 밸런스가 팽팽한 접전 예상",
reasonShort: "중원 힘싸움이 끝까지 팽팽",
generatedAt: "2026-06-08",
},
{
@ -38,17 +42,111 @@ export const AI_PREDICTIONS: AIPrediction[] = [
scoreA: 0,
scoreB: 2,
confidencePct: 28,
reasonShort: "체코의 전환 속도와 결정력 우세",
reasonShort: "체코 역습 한 방이 매섭다",
generatedAt: "2026-06-08",
},
];
export const CROWD: CrowdStats = {
matchId: MATCH.matchId,
total: 1284,
teamAWin: 616, // 48%
draw: 347, // 27%
teamBWin: 321, // 25%
// ── 결정론적 생성 (matchId 시드) ──────────────────────────────
function hash(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
const MODELS: ModelName[] = ["GPT", "Claude", "Gemini"];
// AI 셋이 갈리도록 만든 결과 조합 (홈/원정 균형)
const OUTCOME_SETS: Outcome[][] = [
["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"],
["TEAM_A_WIN", "TEAM_A_WIN", "DRAW"],
["TEAM_B_WIN", "DRAW", "TEAM_A_WIN"],
["TEAM_A_WIN", "DRAW", "TEAM_A_WIN"],
["DRAW", "TEAM_B_WIN", "TEAM_A_WIN"],
];
const SCORE_BY_OUTCOME: Record<Outcome, [number, number][]> = {
TEAM_A_WIN: [[2, 1], [1, 0], [2, 0], [3, 1]],
DRAW: [[1, 1], [0, 0], [2, 2]],
TEAM_B_WIN: [[0, 1], [1, 2], [0, 2], [1, 3]],
};
export const LANDING_URL = "https://triplepick.ai/predict/korea-vs-czechia";
function reasonFor(match: Match, outcome: Outcome, seed: number): string {
const A = match.teamA.shortName;
const B = match.teamB.shortName;
const win = outcome === "TEAM_A_WIN" ? A : outcome === "TEAM_B_WIN" ? B : null;
const winPool = win
? [
`${win} 측면 스피드가 한 끗 위`,
`${win} 세트피스 한 방이면 끝`,
`${win} 초반 기세에서 앞선다`,
`${win} 결정력이 승부를 가른다`,
]
: [];
const drawPool = [
"중원 힘싸움이 끝까지 팽팽",
"양 팀 다 한 방 있는 진검승부",
"first goal이 모든 걸 가른다",
];
const pool = win ? winPool : drawPool;
return pool[seed % pool.length];
}
function generatePredictions(match: Match): AIPrediction[] {
const base = hash(match.matchId);
const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length];
return MODELS.map((model, i) => {
const seed = hash(match.matchId + model);
const outcome = outcomes[i];
const scores = SCORE_BY_OUTCOME[outcome];
const [scoreA, scoreB] = scores[seed % scores.length];
const confidencePct =
outcome === "DRAW" ? 33 + (seed % 13) : 46 + (seed % 25); // draw 33~45, win 46~70
return {
matchId: match.matchId,
model,
outcome,
scoreA,
scoreB,
confidencePct,
reasonShort: reasonFor(match, outcome, seed),
generatedAt: "2026-06-08",
};
});
}
export function getPredictions(match: Match): AIPrediction[] {
if (match.matchId === FEATURED.matchId) return FEATURED_PREDICTIONS;
return generatePredictions(match);
}
export function getCrowd(match: Match): CrowdStats {
if (match.matchId === FEATURED.matchId) {
return { matchId: MATCH.matchId, total: 1284, teamAWin: 616, draw: 347, teamBWin: 321 };
}
const base = hash(match.matchId + "crowd");
const total = 240 + (base % 1400);
const a = 28 + (base % 40); // 28~67%
const d = 18 + ((base >> 3) % 22); // 18~39%
const teamAWin = Math.round((total * a) / 100);
const draw = Math.round((total * Math.min(d, 100 - a - 5)) / 100);
const teamBWin = total - teamAWin - draw;
return { matchId: match.matchId, total, teamAWin, draw, teamBWin };
}
// 경기별 공유 딥링크 (D8)
const ORIGIN = "https://triplepick-web.vercel.app";
export function matchUrl(matchId: string): string {
return `${ORIGIN}/match/${matchId}`;
}
// ── 하위 호환 (체코전 단일 참조용) ──
export const AI_PREDICTIONS: AIPrediction[] = FEATURED_PREDICTIONS;
export const CROWD: CrowdStats = getCrowd(FEATURED);
export const LANDING_URL = matchUrl(FEATURED.matchId);
// schedule re-export (페이지에서 한 곳에서 import)
export { GROUP_A };

View File

@ -25,7 +25,7 @@ export const GROUP_A: Match[] = [
opensAt: opens("2026-06-12T04:00:00+09:00"),
lockAt: "2026-06-12T04:00:00+09:00",
status: "upcoming",
hookText: "월드컵 개막전, AI는 개최국을 믿을까",
hookText: "글로벌 축구 개막전, AI는 개최국을 믿을까",
result: null,
},
{
@ -100,7 +100,7 @@ export const GROUP_A: Match[] = [
},
];
// 현재 노출할 대표 경기
// 현재 노출할 대표 경기 (체코전)
export const FEATURED_MATCH_ID = "A_KOR_CZE_20260612";
export function getMatch(id: string): Match {
@ -108,3 +108,28 @@ export function getMatch(id: string): Match {
}
export const FEATURED = getMatch(FEATURED_MATCH_ID);
// 투표창 단계(D7): 오픈 전 / 투표중 / 마감(킥오프) / 종료(결과). DEMO_FORCE_OPEN는 UI 측에서 별도 처리.
export type MatchPhase = "scheduled" | "open" | "locked" | "finished";
export function matchPhase(m: Match, now: number = Date.now()): MatchPhase {
if (m.result) return "finished";
if (now >= new Date(m.lockAt).getTime()) return "locked";
if (now < new Date(m.opensAt).getTime()) return "scheduled";
return "open";
}
// 대시보드: 날짜별로 묶고 킥오프 오름차순 정렬
export function matchesByDate(): { date: string; matches: Match[] }[] {
const sorted = [...GROUP_A].sort(
(a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(),
);
const groups: { date: string; matches: Match[] }[] = [];
for (const m of sorted) {
const key = m.kickoffKst.slice(0, 10);
const last = groups[groups.length - 1];
if (last && last.date === key) last.matches.push(m);
else groups.push({ date: key, matches: [m] });
}
return groups;
}