Add /before route: restore original v1 design for Before/After record

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Haewon Kam 2026-06-08 21:41:55 +09:00
parent fa68169ca0
commit 5ba653cfdf
21 changed files with 1277 additions and 97 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "triplepick",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "3737"],
"port": 3737
}
]
}

83
HANDOFF.md Normal file
View File

@ -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`(공백 있음)이라 앱을 분리 배치함.

251
app/before/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

370
components/Arena.tsx Normal file
View File

@ -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}&nbsp;-&nbsp;{p.scoreB}
</div>
<div className="w-11 text-right text-[18px] font-extrabold">
{p.confidencePct}%
</div>
</div>
</div>
<div className="mt-2.5 h-2.5 w-full overflow-hidden rounded-full bg-[#eceee9]">
<div
className="bar-anim h-full rounded-full"
style={{
width: `${p.confidencePct}%`,
background: MODEL_COLOR[p.model],
}}
/>
</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>
);
}

11
components/BallIcon.tsx Normal file
View File

@ -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))" }}
/>
);
}

28
components/Footer.tsx Normal file
View File

@ -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>
);
}

26
components/Hero.tsx Normal file
View File

@ -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>
);
}

91
components/MatchupHUD.tsx Normal file
View File

@ -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>
);
}

23
lib/format.ts Normal file
View File

@ -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);
}

50
lib/mockData.ts Normal file
View File

@ -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";

103
lib/schedule.ts Normal file
View File

@ -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);

52
lib/types.ts Normal file
View File

@ -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;
}

BIN
public/icons/ball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

BIN
public/icons/claude.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
public/icons/gemini.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/icons/gpt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/icons/kor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
public/icons/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB