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
parent
0a96babc98
commit
a0fc9d6194
61
HANDOFF.md
61
HANDOFF.md
|
|
@ -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 생성/구현
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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와 겨뤄요.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
23
app/page.tsx
23
app/page.tsx
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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} - {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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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))" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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스포츠 승부예측 톤, 다크 토큰 유지
|
||||
|
|
|
|||
|
|
@ -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만원"** 권장.
|
||||
|
|
@ -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 = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
|
|
|
|||
124
lib/mockData.ts
124
lib/mockData.ts
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue