Add /before route: restore original v1 design for Before/After record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
fa68169ca0
commit
5ba653cfdf
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "triplepick",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "3737"],
|
||||
"port": 3737
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# TriplePick Web — HANDOFF (다음 세션용)
|
||||
|
||||
최종 업데이트: 2026-06-08 KST
|
||||
작성: 프론트엔드 R1(mock) 완료 시점
|
||||
|
||||
> 이 문서는 **새 세션에서 이어받기** 위한 것. 새 세션은 이전 대화 기억이 없으니 여기 + 아래 기획 문서만 보고 작업한다.
|
||||
|
||||
## 0. 이 프로젝트가 뭔가
|
||||
2026 월드컵 **TriplePick** AI 승부예측 랜딩. 한국 vs 체코 단일 경기 R1.
|
||||
사용자가 GPT·Claude·Gemini 예측을 보고 자기 픽을 제출 → 같은 선택 AI 확인 → 공유 → 100만원 챌린지.
|
||||
|
||||
기획 SSOT (상위 폴더):
|
||||
- `../Worldcup 2026/000. Plan/Landing PRD — TriplePick 2026.md`
|
||||
- `../Worldcup 2026/000. Plan/Landing 기능정의서 — TriplePick 2026.md`
|
||||
- 디자인: `../Worldcup 2026/000. Plan/002. Landing Visuals/TriplePick2026 Profile image/`
|
||||
|
||||
## 1. 현재 상태 (DONE — 프론트 R1, mock 데이터)
|
||||
- Next.js 16 + React 19 + Tailwind v4 (App Router, TS)
|
||||
- 모바일 우선, 다크 그린 스코어보드 톤 (디자인 002/003 정합)
|
||||
- 구현 섹션: TopBar(카운트다운·참여수·CTA) / Hero / MatchCard / AIPredictions(3모델 confidence바) / Experience(픽·제출·결과·공유·Crowd) / PrizeChallenge / Footer(법무 4고지)
|
||||
- 인터랙션 검증 완료: 픽 선택 → 닉네임 → 제출 → 결과(같은 AI 계산) → Crowd 실시간 증가
|
||||
- `npm run build` 통과
|
||||
|
||||
## 2. 실행 방법
|
||||
```bash
|
||||
cd /Users/haewonkam/Claude/AIO2O/triplepick-web
|
||||
npm install # 최초 1회
|
||||
npm run dev -- --port 3737
|
||||
# http://localhost:3737
|
||||
```
|
||||
|
||||
## 3. 파일 맵
|
||||
```
|
||||
app/layout.tsx 메타데이터·OG·SEO (ko)
|
||||
app/page.tsx Hero → MatchupHUD → Arena → Footer
|
||||
app/globals.css 테마 토큰, .sheet(레이어드 화이트), .bracket(HUD), .btn-mint, .gold-*
|
||||
lib/types.ts 도메인 타입 (Firestore 변환기에 재사용)
|
||||
lib/mockData.ts ⚠️ MOCK — Firebase 연결 시 여기부터 교체
|
||||
lib/format.ts 라벨/시간/퍼센트 포맷
|
||||
components/Hero.tsx 헤더 + 후킹 (다크)
|
||||
components/MatchupHUD.tsx Match01→Final + 코너브래킷 + 녹색글로우 VS (003 대결 강조)
|
||||
components/Arena.tsx ⚠️ 레이어드 화이트 시트(AI예측+픽+결과) + 골드 상금 + Crowd (client) — submit이 mock
|
||||
components/Footer.tsx 고지 (003 fine print + 법무)
|
||||
```
|
||||
|
||||
> 디자인 정합: 002(레이어드 화이트 백그라운드) + 003(매칭 대결 강조·Match01→Final·골드 ₩1,000,000·Crowd 가로바). 버튼 민트 #85FCA8(--mint). 모델 컬러 GPT #19c37d / Claude #e8814a / Gemini #5b8cff.
|
||||
> 참고: 002/003에 없는 sticky 카운트다운 TopBar는 디자인 충실성을 위해 제거함(PRD의 카운트다운/마감 로직은 Firebase 단계에서 헤더 또는 픽 옆에 재도입 가능). 세그먼트 기본 선택 없음(픽 강제) — 목업은 한국 승 선택 상태로 표시되어 있으니 기본값 원하면 Arena의 useState(outcome) 기본값 지정.
|
||||
|
||||
## 4. 다음 세션 할 일 — Firebase 연결 (NEW 프로젝트)
|
||||
|
||||
### 사용자(나)에게 요청할 것 ⬇ (새 세션이 물어볼 항목)
|
||||
1. **새 Firebase 프로젝트 생성** 후: `projectId`, 웹앱 config 6키
|
||||
(apiKey, authDomain, projectId, storageBucket, messagingSenderId, appId)
|
||||
2. Firestore 사용 설정(Native 모드), 리전 선택(asia-northeast3 서울 권장)
|
||||
3. (선택) 결과 알림 이메일 발송용 키(Resend/SendGrid)
|
||||
4. 도메인 결정(triplepick.ai 등) — 미정이면 Firebase Hosting 기본 도메인
|
||||
|
||||
### 연결 작업 순서
|
||||
1. `npm i firebase` + `lib/firebase.ts`(초기화, env에서 config 로드)
|
||||
2. `.env.local`에 NEXT_PUBLIC_FIREBASE_* 6키
|
||||
3. `lib/mockData.ts` → `lib/data.ts`로 교체: `getMatch / getAIPredictions / getCrowdStats` Firestore 읽기
|
||||
4. `components/Experience.tsx`의 `confirmSubmit` mock → `submitPrediction`(Firestore write + 트랜잭션 crowd 증분)으로 교체
|
||||
5. Firestore 보안 규칙 작성(기능정의서 §7): 쓰기 검증(마감·outcome·score·nickname), device_id 중복 방지
|
||||
6. 시드: matches/ai_predictions 문서 입력(현재 mockData 값 사용)
|
||||
7. Cloud Functions: `onResultSet → scoreAll`(채점), `subscribeResult`, OG `generateShareCard`
|
||||
8. GA4 이벤트 연결(기능정의서 §6), UTM 보존
|
||||
9. `firebase init hosting` + `next build` 배포
|
||||
|
||||
### 데이터 모델·API·채점·인수조건
|
||||
→ `기능정의서 §3~§8` 그대로 구현. 타입은 `lib/types.ts`에 이미 정의됨.
|
||||
|
||||
## 5. 남은 폴리시/TODO (R1 마감 전)
|
||||
- [ ] 결과 카드 문구 "선택입니다.나머지" 사이 공백 추가(Experience.tsx)
|
||||
- [ ] 공유 카드 OG 동적 이미지(@vercel/og) — 현재 텍스트 공유만
|
||||
- [ ] 카카오 공유 SDK 연동(국내 1순위)
|
||||
- [ ] 폰트: Pretendard 웹폰트 로드(현재 시스템 폴백)
|
||||
- [ ] 국기 이모지 → 에셋 이미지(선택)
|
||||
- [ ] 마감(lockAt) 지난 상태 QA, device_id 중복 제출 QA
|
||||
- [ ] 접근성(터치 타깃·대비) 점검
|
||||
|
||||
## 6. 주의
|
||||
- `lib/mockData.ts`의 AI 예측은 **플레이스홀더**. 발행 전 실제 GPT/Claude/Gemini JSON으로 교체.
|
||||
- 경로에 공백 없음(`triplepick-web`) — 툴링 안전. 상위 캠페인 폴더는 `Worldcup 2026`(공백 있음)이라 앱을 분리 배치함.
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// BEFORE — 최초 구축 버전 (다크 네이비 토큰 테마) · 개인 기록용 보존
|
||||
// 002/003 리디자인 이전 모습. 자체 완결(explicit hex), 현재 globals와 독립.
|
||||
// ============================================================
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const C = {
|
||||
bg: "#0a0e1a",
|
||||
bg2: "#0d1322",
|
||||
card: "#141b2d",
|
||||
green: "#25e07f",
|
||||
amber: "#ffb23e",
|
||||
muted: "#8b94a7",
|
||||
line: "#26304a",
|
||||
gpt: "#25e07f",
|
||||
claude: "#c08bff",
|
||||
gemini: "#4d9bff",
|
||||
};
|
||||
|
||||
const MATCH = {
|
||||
teamA: { short: "한국", name: "Korea Republic", flag: "🇰🇷" },
|
||||
teamB: { short: "체코", name: "Czechia", flag: "🇨🇿" },
|
||||
kickoff: "2026-06-12T11:00:00+09:00",
|
||||
lockAt: "2026-06-12T10:50:00+09:00",
|
||||
venue: "Estadio Guadalajara",
|
||||
};
|
||||
const AIS = [
|
||||
{ model: "GPT", color: C.gpt, outcome: "A", score: "2 - 1", conf: 62, reason: "전환 속도와 핵심 공격 자원에서 우위", out: "한국 승" },
|
||||
{ model: "Claude", color: C.claude, outcome: "D", score: "1 - 1", conf: 41, reason: "체코 수비 조직력과 세트피스가 변수", out: "무승부" },
|
||||
{ model: "Gemini", color: C.gemini, outcome: "B", score: "0 - 2", conf: 28, reason: "체코의 원정 폼과 역습 효율을 높게 평가", out: "체코 승" },
|
||||
];
|
||||
|
||||
function useCountdown(iso: string) {
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
if (now === null) return null;
|
||||
const diff = Math.max(0, new Date(iso).getTime() - now);
|
||||
return {
|
||||
d: Math.floor(diff / 86400000),
|
||||
h: Math.floor((diff % 86400000) / 3600000),
|
||||
m: Math.floor((diff % 3600000) / 60000),
|
||||
s: Math.floor((diff % 60000) / 1000),
|
||||
};
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export default function Before() {
|
||||
const cd = useCountdown(MATCH.kickoff);
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
const [a, setA] = useState(1);
|
||||
const [b, setB] = useState(0);
|
||||
const [step, setStep] = useState<"pick" | "form" | "done">("pick");
|
||||
const [nick, setNick] = useState("");
|
||||
const [total, setTotal] = useState(1284);
|
||||
const [dist, setDist] = useState({ A: 48, D: 27, B: 25 });
|
||||
|
||||
const matched = useMemo(() => AIS.filter((p) => p.outcome === outcome).map((p) => p.model), [outcome]);
|
||||
|
||||
const wrap: React.CSSProperties = { background: C.bg, color: "#fff", minHeight: "100dvh" };
|
||||
const card: React.CSSProperties = { background: C.card, border: `1px solid ${C.line}`, borderRadius: 12 };
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
{/* 기록용 라벨 */}
|
||||
<div style={{ background: "#1c2640", color: C.green, textAlign: "center", fontSize: 11, fontWeight: 700, padding: "6px" }}>
|
||||
BEFORE — 최초 구축 버전 (기록용) · 현재 버전은 <a href="/" style={{ color: "#fff", textDecoration: "underline" }}>/ 에서</a>
|
||||
</div>
|
||||
|
||||
{/* Sticky Top */}
|
||||
<div style={{ position: "sticky", top: 0, zIndex: 50, background: "rgba(10,14,26,0.9)", borderBottom: `1px solid ${C.line}`, backdropFilter: "blur(6px)" }}>
|
||||
<div style={{ maxWidth: 480, margin: "0 auto", padding: "0 16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: C.green }}>TriplePick</span>
|
||||
<span style={{ fontSize: 11, color: C.muted }}>🇰🇷 한국 vs 체코 🇨🇿</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ textAlign: "right", lineHeight: 1 }}>
|
||||
<div style={{ fontSize: 9, color: C.muted }}>킥오프까지</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
|
||||
{cd ? `${cd.d}일 ${pad(cd.h)}:${pad(cd.m)}:${pad(cd.s)}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => document.getElementById("pick")?.scrollIntoView({ behavior: "smooth" })} style={{ background: C.green, color: "#06210f", fontSize: 12, fontWeight: 700, borderRadius: 6, padding: "6px 12px", border: 0 }}>
|
||||
내 픽하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: C.muted, paddingBottom: 6 }}>● {total.toLocaleString()}명 픽 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main style={{ maxWidth: 480, margin: "0 auto", padding: "0 16px 64px" }}>
|
||||
{/* Hero */}
|
||||
<header style={{ paddingTop: 20, paddingBottom: 12, textAlign: "center" }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800 }}>Triple Pick <span style={{ color: C.green }}>2026</span></div>
|
||||
<div style={{ fontSize: 12, color: C.muted }}>AI Prediction Arena</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 800, marginTop: 16, lineHeight: 1.35 }}>
|
||||
한국 첫 경기,<br /><span style={{ color: C.green }}>AI의 선택은 갈렸다</span>
|
||||
</h1>
|
||||
<p style={{ fontSize: 12.5, color: C.muted, marginTop: 8, lineHeight: 1.6 }}>
|
||||
같은 데이터를 본 AI 셋이 서로 다른 예측을 했습니다.<br />당신의 픽을 찍고, 경기 끝나면 누가 맞았는지 확인하세요.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Match card */}
|
||||
<section style={{ ...card, padding: 16, marginTop: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}>
|
||||
<span style={{ border: `1px solid ${C.line}`, borderRadius: 999, padding: "2px 8px", color: C.green, fontWeight: 600 }}>Match 01</span>
|
||||
<span>Matchday Prediction</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr", alignItems: "center", gap: 8, marginTop: 12 }}>
|
||||
<Team flag={MATCH.teamA.flag} short={MATCH.teamA.short} name={MATCH.teamA.name} />
|
||||
<div style={{ fontSize: 18, fontWeight: 800, color: C.muted }}>VS</div>
|
||||
<Team flag={MATCH.teamB.flag} short={MATCH.teamB.short} name={MATCH.teamB.name} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12, textAlign: "center", fontSize: 11, color: C.muted }}>6.12(금) 11:00 KST · Group A</div>
|
||||
<div style={{ marginTop: 2, textAlign: "center", fontSize: 10, color: C.muted }}>{MATCH.venue}</div>
|
||||
</section>
|
||||
|
||||
{/* AI predictions */}
|
||||
<section style={{ marginTop: 20 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 800 }}>AI의 예측</h2>
|
||||
<span style={{ fontSize: 10, color: C.muted }}>같은 데이터 · 다른 답</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{AIS.map((p) => (
|
||||
<div key={p.model} style={{ ...card, padding: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 28, height: 28, display: "grid", placeItems: "center", borderRadius: 6, fontSize: 11, fontWeight: 800, background: `${p.color}2e`, color: p.color }}>{p.model[0]}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{p.model}</div>
|
||||
<div style={{ fontSize: 10, color: C.muted, marginTop: 2 }}>{p.out} 예측</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{p.score}</div>
|
||||
<div style={{ fontSize: 10, color: C.muted }}>확신도 {p.conf}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, height: 6, borderRadius: 999, background: C.bg2, overflow: "hidden" }}>
|
||||
<div style={{ width: `${p.conf}%`, height: "100%", background: p.color, borderRadius: 999 }} />
|
||||
</div>
|
||||
<p style={{ marginTop: 8, fontSize: 11, color: C.muted }}>{p.reason} <span style={{ opacity: 0.6 }}>· 기준 2026-06-08</span></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Your pick */}
|
||||
<section id="pick" style={{ marginTop: 20, scrollMarginTop: 80 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 800, marginBottom: 8 }}>당신의 선택 <span style={{ color: C.amber }}>🟠</span></h2>
|
||||
<div style={{ ...card, padding: 16 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, background: C.bg2, borderRadius: 8, padding: 4 }}>
|
||||
{([["A", "한국 승"], ["D", "무승부"], ["B", "체코 승"]] as [string, string][]).map(([v, l]) => (
|
||||
<button key={v} onClick={() => setOutcome(v)} style={{ borderRadius: 6, padding: "8px 0", fontSize: 12.5, fontWeight: 700, border: 0, background: outcome === v ? C.green : "transparent", color: outcome === v ? "#06210f" : C.muted }}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, marginTop: 16 }}>
|
||||
<Stepper label="한국" value={a} set={setA} c={C} />
|
||||
<span style={{ fontSize: 18, fontWeight: 800, color: C.muted, paddingTop: 20 }}>:</span>
|
||||
<Stepper label="체코" value={b} set={setB} c={C} />
|
||||
</div>
|
||||
{step === "pick" && (
|
||||
<button onClick={() => outcome && setStep("form")} disabled={!outcome} style={{ marginTop: 16, width: "100%", borderRadius: 8, padding: "12px 0", fontSize: 15, fontWeight: 800, border: 0, background: C.green, color: "#06210f", opacity: outcome ? 1 : 0.4 }}>AI와 겨루기</button>
|
||||
)}
|
||||
{step === "form" && (
|
||||
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<input value={nick} onChange={(e) => setNick(e.target.value)} placeholder="닉네임 (2~16자)" style={{ width: "100%", borderRadius: 8, border: `1px solid ${C.line}`, background: C.bg2, padding: "10px 12px", fontSize: 13, color: "#fff" }} />
|
||||
<button onClick={() => { if (nick.trim().length >= 2) { setTotal((t) => t + 1); setStep("done"); } }} disabled={nick.trim().length < 2} style={{ width: "100%", borderRadius: 8, padding: "12px 0", fontSize: 15, fontWeight: 800, border: 0, background: C.green, color: "#06210f", opacity: nick.trim().length >= 2 ? 1 : 0.4 }}>제출하고 AI와 겨루기</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{step === "done" && outcome && (
|
||||
<div style={{ ...card, border: `1px solid ${C.green}80`, padding: 16, marginTop: 12 }}>
|
||||
<div style={{ fontSize: 11, color: C.muted }}>내 예측</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginTop: 2 }}>한국 {a}-{b} 체코 <span style={{ fontSize: 13, color: C.green }}>{outcome === "A" ? "한국 승" : outcome === "B" ? "체코 승" : "무승부"}</span></div>
|
||||
<p style={{ fontSize: 12.5, color: "#cfd6e2", marginTop: 8, lineHeight: 1.6 }}>
|
||||
{matched.length ? <>당신은 <b>{matched.join("·")}</b>와 같은 선택입니다. 나머지 AI는 생각이 다릅니다.</> : <>당신은 AI 셋 모두와 다른 선택을 했습니다. 소신 픽! 🔥</>}<br />경기 끝나면 누가 맞았는지 알려드릴게요.
|
||||
</p>
|
||||
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, border: 0, background: C.green, color: "#06210f" }}>내 픽 공유하기</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Crowd (세로 바) */}
|
||||
<section style={{ marginTop: 20 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 800 }}>Crowd Pick</h2>
|
||||
<span style={{ fontSize: 10, color: C.muted }}>● {total.toLocaleString()}명 참여</span>
|
||||
</div>
|
||||
<div style={{ ...card, padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{([["한국 승", dist.A], ["무승부", dist.D], ["체코 승", dist.B]] as [string, number][]).map(([l, v]) => (
|
||||
<div key={l}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 4 }}><span style={{ color: C.muted }}>{l}</span><span style={{ fontWeight: 700 }}>{v}%</span></div>
|
||||
<div style={{ height: 8, borderRadius: 999, background: C.bg2, overflow: "hidden" }}><div style={{ width: `${v}%`, height: "100%", background: C.green, borderRadius: 999 }} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Prize */}
|
||||
<section style={{ marginTop: 20 }}>
|
||||
<div style={{ ...card, padding: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🏆</span><h2 style={{ fontSize: 14, fontWeight: 800 }}>월드컵 끝까지 맞히면 100만 원</h2></div>
|
||||
<p style={{ marginTop: 6, fontSize: 12, color: C.muted, lineHeight: 1.6 }}>AI 이기고 끝까지 살아남으면 100만 원. 매 경기 픽하고, 최종 우승팀과 결승 스코어까지 맞히면 도전 자격.</p>
|
||||
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, background: `${C.green}1a`, color: C.green, border: `1px solid ${C.green}66` }}>100만원 도전하기</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{ marginTop: 32, borderTop: `1px solid ${C.line}`, paddingTop: 20 }}>
|
||||
<p style={{ fontSize: 11, color: C.muted, lineHeight: 1.6 }}>이 캠페인은 AIO2O가 운영하는 AI 예측 실험입니다. GPT·Claude·Gemini에 동일한 경기 데이터를 제공하고 모델별 예측 차이를 기록합니다.</p>
|
||||
<p style={{ fontSize: 10, color: `${C.muted}cc`, marginTop: 8, lineHeight: 1.6 }}>본 콘텐츠는 스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.</p>
|
||||
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}><span>© 2026 TriplePick · 운영 AIO2O</span><span>@triplepick_ai</span></div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Team({ flag, short, name }: { flag: string; short: string; name: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
|
||||
<div style={{ fontSize: 40, lineHeight: 1 }}>{flag}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{short}</div>
|
||||
<div style={{ fontSize: 9, color: "#8b94a7" }}>{name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Stepper({ label, value, set, c }: { label: string; value: number; set: (n: number) => void; c: typeof C }) {
|
||||
const btn: React.CSSProperties = { width: 36, height: 36, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${c.line}`, background: c.bg2, color: "#fff", fontSize: 18, fontWeight: 700 };
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div style={{ fontSize: 11, color: c.muted, marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button onClick={() => set(Math.max(0, value - 1))} style={btn}>−</button>
|
||||
<div style={{ width: 36, textAlign: "center", fontSize: 22, fontWeight: 800 }}>{value}</div>
|
||||
<button onClick={() => set(Math.min(9, value + 1))} style={btn}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
app/globals.css
156
app/globals.css
|
|
@ -1,26 +1,152 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--bg: #14171c; /* 차콜 다크 */
|
||||
--bg2: #1b1f26;
|
||||
--mint: #85fca8; /* CTA 버튼 */
|
||||
--mint-ink: #0a2417; /* 민트 버튼 위 텍스트 */
|
||||
--green: #3ee08a; /* 글로우/액센트 */
|
||||
|
||||
--sheet: #f2f3ef; /* 레이어드 화이트 시트 */
|
||||
--sheet-card: #ffffff;
|
||||
--ink: #14171c; /* 화이트 위 텍스트 */
|
||||
--ink-muted: #79808c;
|
||||
--line-d: #2b313c; /* 다크 보더 */
|
||||
--line-l: #e3e5df; /* 라이트 보더 */
|
||||
|
||||
/* 상금 카드 — 골드 대신 민트/틸그린 쿨 톤 */
|
||||
--gold-border: #5ef0b4; /* 민트 액센트 */
|
||||
--gold-bg: #0e1f1b; /* 딥 틸그린 */
|
||||
--gold-btn-1: #85fca8; /* 민트 */
|
||||
--gold-btn-2: #15a99b; /* 틸그린 */
|
||||
|
||||
--gpt: #15a99b; /* 틸그린 (teal green) */
|
||||
--claude: #e8814a;
|
||||
--gemini: #5b8cff;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
background:
|
||||
radial-gradient(120% 50% at 50% -5%, #20302a 0%, var(--bg) 48%) no-repeat,
|
||||
var(--bg);
|
||||
color: #fff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family:
|
||||
"Pretendard", -apple-system, BlinkMacSystemFont, system-ui,
|
||||
"Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 56px;
|
||||
}
|
||||
|
||||
/* Triple Pick 2026 타이틀용 Impact */
|
||||
.font-impact {
|
||||
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", system-ui,
|
||||
sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.flag-img {
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-d);
|
||||
}
|
||||
|
||||
/* 레이어드 화이트 시트 */
|
||||
.sheet {
|
||||
background: var(--sheet);
|
||||
border-radius: 22px;
|
||||
box-shadow:
|
||||
0 -2px 0 rgba(255, 255, 255, 0.04),
|
||||
0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 코너 브래킷 (HUD) */
|
||||
.bracket {
|
||||
position: relative;
|
||||
}
|
||||
.bracket > .bk {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--green);
|
||||
}
|
||||
.bracket > .bk-tl {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-top-left-radius: 6px;
|
||||
}
|
||||
.bracket > .bk-tr {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-left: 0;
|
||||
border-bottom: 0;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
.bracket > .bk-br {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
.bracket > .bk-bl {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.vs-glow {
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
rgba(62, 224, 138, 0.5),
|
||||
rgba(62, 224, 138, 0) 70%
|
||||
);
|
||||
}
|
||||
|
||||
.btn-mint {
|
||||
background: var(--mint);
|
||||
color: var(--mint-ink);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(133, 252, 168, 0.55),
|
||||
0 10px 28px rgba(133, 252, 168, 0.28);
|
||||
}
|
||||
|
||||
.gold-card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(94, 240, 180, 0.08), rgba(21, 169, 155, 0.03)),
|
||||
var(--gold-bg);
|
||||
border: 1px solid rgba(94, 240, 180, 0.45);
|
||||
box-shadow: inset 0 0 24px rgba(94, 240, 180, 0.06);
|
||||
}
|
||||
.gold-btn {
|
||||
background: linear-gradient(180deg, var(--gold-btn-1), var(--gold-btn-2));
|
||||
color: #06231a;
|
||||
box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3);
|
||||
}
|
||||
|
||||
@keyframes barfill {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
.bar-anim {
|
||||
animation: barfill 0.8s ease-out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,38 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "한국 vs 체코 AI 승부예측 | GPT vs Claude vs Gemini — TriplePick",
|
||||
description:
|
||||
"GPT·Claude·Gemini가 한국 vs 체코를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 맞히면 100만 원 챌린지.",
|
||||
applicationName: "TriplePick",
|
||||
openGraph: {
|
||||
title: "AI 셋이 갈렸다 — 한국 vs 체코, 당신의 픽은?",
|
||||
description:
|
||||
"GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어보세요. TriplePick AI 예측 아레나.",
|
||||
siteName: "TriplePick",
|
||||
type: "website",
|
||||
locale: "ko_KR",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "AI 셋이 갈렸다 — 한국 vs 체코",
|
||||
description: "GPT vs Claude vs Gemini vs 너. 지금 픽하고 AI와 겨뤄요.",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#0a0e1a",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="ko">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
70
app/page.tsx
70
app/page.tsx
|
|
@ -1,65 +1,15 @@
|
|||
import Image from "next/image";
|
||||
import Hero from "@/components/Hero";
|
||||
import MatchupHUD from "@/components/MatchupHUD";
|
||||
import Arena from "@/components/Arena";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<main className="shell">
|
||||
<Hero />
|
||||
<MatchupHUD />
|
||||
<Arena />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL } from "@/lib/mockData";
|
||||
import { outcomeLabel, pct } from "@/lib/format";
|
||||
import type { Outcome, CrowdStats, ModelName } from "@/lib/types";
|
||||
|
||||
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 [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 [copied, setCopied] = useState(false);
|
||||
|
||||
const locked = Date.now() >= new Date(MATCH.lockAt).getTime();
|
||||
|
||||
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]);
|
||||
|
||||
const submitPick = () => {
|
||||
if (!outcome) return;
|
||||
setStep("form");
|
||||
};
|
||||
const confirmSubmit = () => {
|
||||
if (nickname.trim().length < 2) return;
|
||||
setCrowd((c) => ({
|
||||
...c,
|
||||
total: c.total + 1,
|
||||
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
|
||||
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
|
||||
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
|
||||
}));
|
||||
setStep("done");
|
||||
};
|
||||
|
||||
const shareText = useMemo(() => {
|
||||
if (!outcome) return "";
|
||||
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]);
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${shareText}\n${LANDING_URL}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
const nativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ text: shareText, url: LANDING_URL });
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
} else copyLink();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== 레이어드 화이트 시트 (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)]" />
|
||||
</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}
|
||||
</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">
|
||||
{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],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] leading-snug text-[var(--ink-muted)]">
|
||||
{p.reasonShort}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 당신의 선택 */}
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
|
||||
Your Pick · 내 예측
|
||||
</span>
|
||||
<span className="text-[20px] font-extrabold">당신의 선택</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{(
|
||||
[
|
||||
["TEAM_A_WIN", `${MATCH.teamA.shortName} 승`],
|
||||
["DRAW", "무승부"],
|
||||
["TEAM_B_WIN", `${MATCH.teamB.shortName} 승`],
|
||||
] as [Outcome, string][]
|
||||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
disabled={locked}
|
||||
onClick={() => setOutcome(val)}
|
||||
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition ${
|
||||
outcome === val
|
||||
? "border-transparent bg-[var(--mint)] text-[var(--mint-ink)]"
|
||||
: "border-[var(--line-l)] bg-white text-[var(--ink-muted)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</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={locked} />
|
||||
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
|
||||
<Stepper label={MATCH.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={locked} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== CTA 민트 #85FCA8 ===== */}
|
||||
{step === "pick" && (
|
||||
<button
|
||||
onClick={submitPick}
|
||||
disabled={!outcome || locked}
|
||||
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
|
||||
>
|
||||
{locked ? "예측 마감" : "AI보다 잘 맞히기"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{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
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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>
|
||||
<button
|
||||
onClick={confirmSubmit}
|
||||
disabled={nickname.trim().length < 2}
|
||||
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와 겨루기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||||
{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) ? " 스코어까지 일치! " : " "}
|
||||
나머지 AI는 생각이 다릅니다.
|
||||
</>
|
||||
) : (
|
||||
<>당신은 AI 셋 모두와 다른 선택! 소신 픽 🔥</>
|
||||
)}
|
||||
</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 ? "복사됨 ✓" : "링크 복사"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 골드 상금 (003) ===== */}
|
||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-[18px] font-extrabold text-[var(--gold-border)]">
|
||||
<span>₩1,000,000 Final Challenge</span>
|
||||
<span>★</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
|
||||
<b className="text-[var(--gold-border)]">NEW</b> 정확한 스코어까지
|
||||
맞히면 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]">
|
||||
100만 원에
|
||||
<br />
|
||||
도전하기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== Crowd Pick (003) ===== */}
|
||||
<section className="mt-6">
|
||||
<div className="mb-2.5 text-[20px] font-extrabold">
|
||||
Crowd Pick{" "}
|
||||
<span className="text-[14px] text-[var(--ink-muted)]">
|
||||
| 참여자들의 선택
|
||||
</span>
|
||||
</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 value={pct(crowd.draw, crowd.total)} tone="draw" />
|
||||
<CrowdSeg cze value={pct(crowd.teamBWin, crowd.total)} tone="cze" />
|
||||
</div>
|
||||
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
|
||||
{crowd.total.toLocaleString()}명 참여
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<StepBtn disabled={disabled || value <= 0} onClick={() => onChange(Math.max(0, value - 1))}>
|
||||
−
|
||||
</StepBtn>
|
||||
<div className="flex w-12 flex-col items-center">
|
||||
<span className="font-mono text-[32px] font-extrabold leading-none tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] text-[var(--ink-muted)]">{label}</span>
|
||||
</div>
|
||||
<StepBtn disabled={disabled || value >= 9} onClick={() => onChange(Math.min(9, value + 1))}>
|
||||
+
|
||||
</StepBtn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepBtn({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="grid h-11 w-11 place-items-center rounded-xl border border-[var(--line-l)] bg-white text-[22px] font-bold leading-none text-[var(--ink)] active:scale-95 disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CrowdSeg({
|
||||
flag,
|
||||
cze,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
flag?: string;
|
||||
cze?: boolean;
|
||||
value: number;
|
||||
tone: "kor" | "draw" | "cze";
|
||||
}) {
|
||||
const bg =
|
||||
tone === "draw" ? "#2b313c" : tone === "kor" ? "rgba(62,224,138,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>
|
||||
)}
|
||||
<span>{value}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// 매치카드 아이콘 = TriplePick 프로필 로고 (그린 글로우)
|
||||
export default function BallIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<img
|
||||
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))" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-6 text-center">
|
||||
<p className="text-[10.5px] font-semibold text-[var(--ink-muted)]">
|
||||
Independent AI prediction event. Not official.
|
||||
</p>
|
||||
<p className="mt-1 text-[10.5px] leading-relaxed text-[var(--ink-muted)]">
|
||||
본 서비스는 독립적인 AI 예측 이벤트이며, 공식·조작·후원·운영과
|
||||
무관합니다.
|
||||
</p>
|
||||
|
||||
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-[var(--ink-muted)]/70">
|
||||
<p>
|
||||
스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지
|
||||
않습니다. AI 예측은 실제 결과를 보장하지 않습니다.
|
||||
</p>
|
||||
<p>
|
||||
100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도
|
||||
약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-[10px] text-[var(--ink-muted)]">
|
||||
© 2026 TriplePick · 운영 AIO2O · @triplepick_ai
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { MATCH } from "@/lib/mockData";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<header className="pt-7 text-center">
|
||||
<div className="font-impact text-[44px] leading-none tracking-tight">
|
||||
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[16px] font-extrabold text-[var(--green)]">
|
||||
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
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { MATCH } from "@/lib/mockData";
|
||||
import { kickoffDisplay } from "@/lib/format";
|
||||
import BallIcon from "./BallIcon";
|
||||
|
||||
export default function MatchupHUD() {
|
||||
const { group, roundLabel } = MATCH;
|
||||
return (
|
||||
<section className="mt-6">
|
||||
{/* Match 01 → 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}
|
||||
</span>
|
||||
<div className="relative h-px flex-1 bg-gradient-to-r from-[var(--green)] to-[var(--line-d)]">
|
||||
<span className="absolute -left-0.5 top-1/2 -translate-y-1/2 text-[var(--green)]">
|
||||
◀
|
||||
</span>
|
||||
<span className="absolute -right-0.5 top-1/2 -translate-y-1/2 text-[var(--line-d)]">
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-md border border-[var(--line-d)] px-2.5 py-1 text-[var(--ink-muted)]">
|
||||
Final
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 대결 카드 (녹색 글로우 보더) */}
|
||||
<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)" }}
|
||||
>
|
||||
{/* 헤더: 축구공 아이콘 + 타이틀 + 일시 */}
|
||||
<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}
|
||||
</div>
|
||||
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
|
||||
{kickoffDisplay(MATCH.kickoffKst)} · Group {group}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/65">{MATCH.venue}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 국기 + 라이트닝 VS */}
|
||||
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<KorFlag />
|
||||
<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>
|
||||
</div>
|
||||
<CzeFlag />
|
||||
</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,23 @@
|
|||
import type { Outcome, Match } from "./types";
|
||||
|
||||
export function outcomeLabel(match: Match, o: Outcome): string {
|
||||
if (o === "TEAM_A_WIN") return `${match.teamA.shortName} 승`;
|
||||
if (o === "TEAM_B_WIN") return `${match.teamB.shortName} 승`;
|
||||
return "무승부";
|
||||
}
|
||||
|
||||
export function kickoffDisplay(iso: string): string {
|
||||
// iso는 KST(+09:00). 런타임 TZ에 무관하게 ISO 문자열을 직접 파싱한다.
|
||||
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!dm) return iso;
|
||||
const [, y, mo, d, hh, mm] = dm;
|
||||
// 요일은 달력 날짜 기준(UTC Date로 계산하면 TZ 무관)
|
||||
const dow = days[new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay()];
|
||||
return `${+mo}.${+d}(${dow}) ${hh}:${mm} KST`;
|
||||
}
|
||||
|
||||
export function pct(part: number, total: number): number {
|
||||
if (!total) return 0;
|
||||
return Math.round((part / total) * 100);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// 1차 mock 데이터 — Firebase 연결 전까지 사용.
|
||||
// ⚠️ AI 예측은 플레이스홀더. 발행 전 GPT/Claude/Gemini 실제 출력(JSON)으로 교체할 것.
|
||||
// 경기 일정은 lib/schedule.ts(Group A 정식 일정, KST)에서 자동 반영.
|
||||
import type { AIPrediction, CrowdStats } from "./types";
|
||||
import { FEATURED } from "./schedule";
|
||||
|
||||
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
|
||||
|
||||
export const AI_PREDICTIONS: AIPrediction[] = [
|
||||
{
|
||||
matchId: MATCH.matchId,
|
||||
model: "GPT",
|
||||
outcome: "TEAM_A_WIN",
|
||||
scoreA: 2,
|
||||
scoreB: 1,
|
||||
confidencePct: 62,
|
||||
reasonShort: "한국의 측면 공격 우위와 세트피스 강점",
|
||||
generatedAt: "2026-06-08",
|
||||
},
|
||||
{
|
||||
matchId: MATCH.matchId,
|
||||
model: "Claude",
|
||||
outcome: "DRAW",
|
||||
scoreA: 1,
|
||||
scoreB: 1,
|
||||
confidencePct: 41,
|
||||
reasonShort: "중원 밸런스가 팽팽한 접전 예상",
|
||||
generatedAt: "2026-06-08",
|
||||
},
|
||||
{
|
||||
matchId: MATCH.matchId,
|
||||
model: "Gemini",
|
||||
outcome: "TEAM_B_WIN",
|
||||
scoreA: 0,
|
||||
scoreB: 2,
|
||||
confidencePct: 28,
|
||||
reasonShort: "체코의 전환 속도와 결정력 우세",
|
||||
generatedAt: "2026-06-08",
|
||||
},
|
||||
];
|
||||
|
||||
export const CROWD: CrowdStats = {
|
||||
matchId: MATCH.matchId,
|
||||
total: 1284,
|
||||
teamAWin: 616, // 48%
|
||||
draw: 347, // 27%
|
||||
teamBWin: 321, // 25%
|
||||
};
|
||||
|
||||
export const LANDING_URL = "https://triplepick.ai/predict/korea-vs-czechia";
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// 2026 FIFA World Cup Group A 정식 일정 (출처: Wikipedia/FIFA, KST 변환 검증 2026-06-08)
|
||||
// 모든 kickoffKst는 한국시간(+09:00). lockAt = 킥오프 -10분.
|
||||
import type { Match, Team } from "./types";
|
||||
|
||||
const KOR: Team = { name: "Korea Republic", shortName: "한국", code: "KOR", flag: "🇰🇷" };
|
||||
const CZE: Team = { name: "Czechia", shortName: "체코", code: "CZE", flag: "🇨🇿" };
|
||||
const MEX: Team = { name: "Mexico", shortName: "멕시코", code: "MEX", flag: "🇲🇽" };
|
||||
const RSA: Team = { name: "South Africa", shortName: "남아공", code: "RSA", flag: "🇿🇦" };
|
||||
|
||||
function lock(iso: string): string {
|
||||
return new Date(new Date(iso).getTime() - 10 * 60000).toISOString();
|
||||
}
|
||||
|
||||
// Group A 전체 6경기 — 경기별 정확 일시 자동 반영용 SSOT
|
||||
export const GROUP_A: Match[] = [
|
||||
{
|
||||
matchId: "A_MEX_RSA_20260612",
|
||||
roundLabel: "Match 01 · 개막전",
|
||||
group: "A",
|
||||
teamA: MEX,
|
||||
teamB: RSA,
|
||||
kickoffKst: "2026-06-12T04:00:00+09:00",
|
||||
venue: "Estadio Azteca · Mexico City",
|
||||
lockAt: lock("2026-06-12T04:00:00+09:00"),
|
||||
status: "upcoming",
|
||||
hookText: "월드컵 개막전, AI는 개최국을 믿을까",
|
||||
result: null,
|
||||
},
|
||||
{
|
||||
matchId: "A_KOR_CZE_20260612",
|
||||
roundLabel: "Match 01",
|
||||
group: "A",
|
||||
teamA: KOR,
|
||||
teamB: CZE,
|
||||
kickoffKst: "2026-06-12T11:00:00+09:00",
|
||||
venue: "Estadio Guadalajara (Akron)",
|
||||
lockAt: lock("2026-06-12T11:00:00+09:00"),
|
||||
status: "upcoming",
|
||||
hookText: "한국 첫 경기, AI의 선택은 갈렸다",
|
||||
result: null,
|
||||
},
|
||||
{
|
||||
matchId: "A_CZE_RSA_20260619",
|
||||
roundLabel: "Match 02",
|
||||
group: "A",
|
||||
teamA: CZE,
|
||||
teamB: RSA,
|
||||
kickoffKst: "2026-06-19T01:00:00+09:00",
|
||||
venue: "USA (TBD)",
|
||||
lockAt: lock("2026-06-19T01:00:00+09:00"),
|
||||
status: "upcoming",
|
||||
hookText: "체코 vs 남아공, AI의 예측은",
|
||||
result: null,
|
||||
},
|
||||
{
|
||||
matchId: "A_MEX_KOR_20260619",
|
||||
roundLabel: "Match 02",
|
||||
group: "A",
|
||||
teamA: MEX,
|
||||
teamB: KOR,
|
||||
kickoffKst: "2026-06-19T10:00:00+09:00",
|
||||
venue: "Estadio Guadalajara (Akron)",
|
||||
lockAt: lock("2026-06-19T10:00:00+09:00"),
|
||||
status: "upcoming",
|
||||
hookText: "개최국 멕시코 vs 한국, AI는 누구 편",
|
||||
result: null,
|
||||
},
|
||||
{
|
||||
matchId: "A_CZE_MEX_20260625",
|
||||
roundLabel: "Match 03",
|
||||
group: "A",
|
||||
teamA: CZE,
|
||||
teamB: MEX,
|
||||
kickoffKst: "2026-06-25T10:00:00+09:00",
|
||||
venue: "Estadio Azteca · Mexico City",
|
||||
lockAt: lock("2026-06-25T10:00:00+09:00"),
|
||||
status: "upcoming",
|
||||
hookText: "체코 vs 멕시코, 운명의 최종전",
|
||||
result: null,
|
||||
},
|
||||
{
|
||||
matchId: "A_RSA_KOR_20260625",
|
||||
roundLabel: "Match 03",
|
||||
group: "A",
|
||||
teamA: RSA,
|
||||
teamB: KOR,
|
||||
kickoffKst: "2026-06-25T10:00:00+09:00",
|
||||
venue: "Estadio BBVA · Monterrey",
|
||||
lockAt: lock("2026-06-25T10:00:00+09:00"),
|
||||
status: "upcoming",
|
||||
hookText: "한국 16강의 갈림길, AI의 마지막 예측",
|
||||
result: null,
|
||||
},
|
||||
];
|
||||
|
||||
// 현재 노출할 대표 경기
|
||||
export const FEATURED_MATCH_ID = "A_KOR_CZE_20260612";
|
||||
|
||||
export function getMatch(id: string): Match {
|
||||
return GROUP_A.find((m) => m.matchId === id) ?? GROUP_A[1];
|
||||
}
|
||||
|
||||
export const FEATURED = getMatch(FEATURED_MATCH_ID);
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// TriplePick 도메인 타입 — 기능정의서 데이터모델과 정합
|
||||
// Firebase 연결 시 이 타입을 Firestore 변환기(converter)에 그대로 사용한다.
|
||||
|
||||
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
|
||||
export type ModelName = "GPT" | "Claude" | "Gemini";
|
||||
export type MatchStatus = "upcoming" | "locked" | "live" | "finished";
|
||||
|
||||
export interface Team {
|
||||
name: string; // 표시명 (예: Korea Republic)
|
||||
shortName: string; // 짧은 표시 (예: 한국)
|
||||
code: string; // KOR / CZE
|
||||
flag: string; // 이모지 국기 (1차), 추후 에셋 교체 가능
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
matchId: string;
|
||||
roundLabel: string; // "Match 01"
|
||||
group: string; // "A"
|
||||
teamA: Team;
|
||||
teamB: Team;
|
||||
kickoffKst: string; // ISO with +09:00
|
||||
venue: string;
|
||||
lockAt: string; // ISO (킥오프 -10분)
|
||||
status: MatchStatus;
|
||||
hookText: string; // "한국 첫 경기, AI의 선택은 갈렸다"
|
||||
result?: { scoreA: number; scoreB: number; outcome: Outcome } | null;
|
||||
}
|
||||
|
||||
export interface AIPrediction {
|
||||
matchId: string;
|
||||
model: ModelName;
|
||||
outcome: Outcome;
|
||||
scoreA: number;
|
||||
scoreB: number;
|
||||
confidencePct: number; // 0-100
|
||||
reasonShort: string;
|
||||
generatedAt: string; // 데이터 기준일
|
||||
}
|
||||
|
||||
export interface CrowdStats {
|
||||
matchId: string;
|
||||
total: number;
|
||||
teamAWin: number;
|
||||
draw: number;
|
||||
teamBWin: number;
|
||||
}
|
||||
|
||||
export interface UserPick {
|
||||
outcome: Outcome;
|
||||
scoreA: number;
|
||||
scoreB: number;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 806 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Loading…
Reference in New Issue