diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..41064af --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "triplepick", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "3737"], + "port": 3737 + } + ] +} diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..296edde --- /dev/null +++ b/HANDOFF.md @@ -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`(공백 있음)이라 앱을 분리 배치함. diff --git a/app/before/page.tsx b/app/before/page.tsx new file mode 100644 index 0000000..bbc7dd6 --- /dev/null +++ b/app/before/page.tsx @@ -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(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(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 ( +
+ {/* 기록용 라벨 */} +
+ BEFORE — 최초 구축 버전 (기록용) · 현재 버전은 / 에서 +
+ + {/* Sticky Top */} +
+
+
+
+ TriplePick + 🇰🇷 한국 vs 체코 🇨🇿 +
+
+
+
킥오프까지
+
+ {cd ? `${cd.d}일 ${pad(cd.h)}:${pad(cd.m)}:${pad(cd.s)}` : "—"} +
+
+ +
+
+
● {total.toLocaleString()}명 픽 완료
+
+
+ +
+ {/* Hero */} +
+
Triple Pick 2026
+
AI Prediction Arena
+

+ 한국 첫 경기,
AI의 선택은 갈렸다 +

+

+ 같은 데이터를 본 AI 셋이 서로 다른 예측을 했습니다.
당신의 픽을 찍고, 경기 끝나면 누가 맞았는지 확인하세요. +

+
+ + {/* Match card */} +
+
+ Match 01 + Matchday Prediction +
+
+ +
VS
+ +
+
6.12(금) 11:00 KST · Group A
+
{MATCH.venue}
+
+ + {/* AI predictions */} +
+
+

AI의 예측

+ 같은 데이터 · 다른 답 +
+
+ {AIS.map((p) => ( +
+
+
+ {p.model[0]} +
+
{p.model}
+
{p.out} 예측
+
+
+
+
{p.score}
+
확신도 {p.conf}%
+
+
+
+
+
+

{p.reason} · 기준 2026-06-08

+
+ ))} +
+
+ + {/* Your pick */} +
+

당신의 선택 🟠

+
+
+ {([["A", "한국 승"], ["D", "무승부"], ["B", "체코 승"]] as [string, string][]).map(([v, l]) => ( + + ))} +
+
+ + : + +
+ {step === "pick" && ( + + )} + {step === "form" && ( +
+ 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" }} /> + +
+ )} +
+ {step === "done" && outcome && ( +
+
내 예측
+
한국 {a}-{b} 체코 {outcome === "A" ? "한국 승" : outcome === "B" ? "체코 승" : "무승부"}
+

+ {matched.length ? <>당신은 {matched.join("·")}와 같은 선택입니다. 나머지 AI는 생각이 다릅니다. : <>당신은 AI 셋 모두와 다른 선택을 했습니다. 소신 픽! 🔥}
경기 끝나면 누가 맞았는지 알려드릴게요. +

+ +
+ )} +
+ + {/* Crowd (세로 바) */} +
+
+

Crowd Pick

+ ● {total.toLocaleString()}명 참여 +
+
+ {([["한국 승", dist.A], ["무승부", dist.D], ["체코 승", dist.B]] as [string, number][]).map(([l, v]) => ( +
+
{l}{v}%
+
+
+ ))} +
+
+ + {/* Prize */} +
+
+
🏆

월드컵 끝까지 맞히면 100만 원

+

AI 이기고 끝까지 살아남으면 100만 원. 매 경기 픽하고, 최종 우승팀과 결승 스코어까지 맞히면 도전 자격.

+ +
+
+ + {/* Footer */} +
+

이 캠페인은 AIO2O가 운영하는 AI 예측 실험입니다. GPT·Claude·Gemini에 동일한 경기 데이터를 제공하고 모델별 예측 차이를 기록합니다.

+

본 콘텐츠는 스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.

+
© 2026 TriplePick · 운영 AIO2O@triplepick_ai
+
+
+
+ ); +} + +function Team({ flag, short, name }: { flag: string; short: string; name: string }) { + return ( +
+
{flag}
+
{short}
+
{name}
+
+ ); +} +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 ( +
+
{label}
+
+ +
{value}
+ +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..5c3cac2 100644 --- a/app/globals.css +++ b/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; } diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..24fbaf5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - {children} + + {children} ); } diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..b047895 100644 --- a/app/page.tsx +++ b/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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+
+ + + +
+
); } diff --git a/components/Arena.tsx b/components/Arena.tsx new file mode 100644 index 0000000..10cb619 --- /dev/null +++ b/components/Arena.tsx @@ -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 = { + GPT: "var(--gpt)", + Claude: "var(--claude)", + Gemini: "var(--gemini)", +}; +const MODEL_ICON: Record = { + GPT: "/icons/gpt.png", + Claude: "/icons/claude.jpg", + Gemini: "/icons/gemini.jpeg", +}; + +export default function Arena() { + const [outcome, setOutcome] = useState(null); + const [scoreA, setScoreA] = useState(2); + const [scoreB, setScoreB] = useState(1); + const [step, setStep] = useState("pick"); + const [nickname, setNickname] = useState(""); + const [email, setEmail] = useState(""); + const [notify, setNotify] = useState(true); + const [crowd, setCrowd] = useState(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) ===== */} +
+
+

AI의 예측

+ +
+ +
+ {AI_PREDICTIONS.map((p) => ( +
+
+ {p.model} +
+
+ {p.model} +
+
+ Confidence +
+
+
+
+ {p.scoreA} - {p.scoreB} +
+
+ {p.confidencePct}% +
+
+
+ +
+
+
+

+ {p.reasonShort} +

+
+ ))} +
+ + {/* 당신의 선택 */} +
+ + Your Pick · 내 예측 + + 당신의 선택 +
+ +
+ {( + [ + ["TEAM_A_WIN", `${MATCH.teamA.shortName} 승`], + ["DRAW", "무승부"], + ["TEAM_B_WIN", `${MATCH.teamB.shortName} 승`], + ] as [Outcome, string][] + ).map(([val, label]) => ( + + ))} +
+ +
+ + : + +
+
+ + {/* ===== CTA 민트 #85FCA8 ===== */} + {step === "pick" && ( + + )} + + {step === "form" && ( +
+ 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)]" + /> + 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)]" + /> + + +
+ )} + + {step === "done" && outcome && ( +
+
내 예측
+
+ {MATCH.teamA.shortName} {scoreA}-{scoreB} {MATCH.teamB.shortName} + + {outcomeLabel(MATCH, outcome)} + +
+

+ {matched.length > 0 ? ( + <> + 당신은 {matched.map((m) => m.model).join("·")}와 같은 + 선택입니다.{matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "} + 나머지 AI는 생각이 다릅니다. + + ) : ( + <>당신은 AI 셋 모두와 다른 선택! 소신 픽 🔥 + )} +

+
+ + +
+
+ )} + + {/* ===== 골드 상금 (003) ===== */} +
+
+
+
+ ₩1,000,000 Final Challenge + +
+

+ NEW 정확한 스코어까지 + 맞히면 100만원 상금에 도전하세요 +

+
+ +
+
+ + {/* ===== Crowd Pick (003) ===== */} +
+
+ Crowd Pick{" "} + + | 참여자들의 선택 + +
+
+ + + +
+
+ {crowd.total.toLocaleString()}명 참여 +
+
+ + ); +} + +function Stepper({ + label, + value, + onChange, + disabled, +}: { + label: string; + value: number; + onChange: (n: number) => void; + disabled?: boolean; +}) { + return ( +
+ onChange(Math.max(0, value - 1))}> + − + +
+ + {value} + + {label} +
+ = 9} onClick={() => onChange(Math.min(9, value + 1))}> + + + +
+ ); +} + +function StepBtn({ + children, + onClick, + disabled, +}: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +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 ( +
+ {flag && KOR} + {cze && ( + + + + + + )} + {value}% +
+ ); +} diff --git a/components/BallIcon.tsx b/components/BallIcon.tsx new file mode 100644 index 0000000..d92a63e --- /dev/null +++ b/components/BallIcon.tsx @@ -0,0 +1,11 @@ +// 매치카드 아이콘 = TriplePick 프로필 로고 (그린 글로우) +export default function BallIcon({ className = "" }: { className?: string }) { + return ( + TriplePick + ); +} diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..57b083c --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,28 @@ +export default function Footer() { + return ( +
+

+ Independent AI prediction event. Not official. +

+

+ 본 서비스는 독립적인 AI 예측 이벤트이며, 공식·조작·후원·운영과 + 무관합니다. +

+ +
+

+ 스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 + 않습니다. AI 예측은 실제 결과를 보장하지 않습니다. +

+

+ 100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 + 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다. +

+
+ +
+ © 2026 TriplePick · 운영 AIO2O · @triplepick_ai +
+
+ ); +} diff --git a/components/Hero.tsx b/components/Hero.tsx new file mode 100644 index 0000000..c4d1af4 --- /dev/null +++ b/components/Hero.tsx @@ -0,0 +1,26 @@ +import { MATCH } from "@/lib/mockData"; + +export default function Hero() { + return ( +
+
+ TRIPLE PICK 2026 +
+
+ AI Prediction Arena +
+
+ AI와 함께하는 월드컵 승부 예측 대결 챌린지 +
+ +

+ + {MATCH.hookText} + +

+
+ Korea Republic vs Czechia · Matchday Prediction +
+
+ ); +} diff --git a/components/MatchupHUD.tsx b/components/MatchupHUD.tsx new file mode 100644 index 0000000..e9c5042 --- /dev/null +++ b/components/MatchupHUD.tsx @@ -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 ( +
+ {/* Match 01 → Final 진행 */} +
+ + {roundLabel} + +
+ + ◀ + + + ▶ + +
+ + Final + +
+ + {/* 대결 카드 (녹색 글로우 보더) */} +
+ {/* 헤더: 축구공 아이콘 + 타이틀 + 일시 */} +
+ +
+
+ {MATCH.teamA.name}{" "} + vs {MATCH.teamB.name} +
+
+ {kickoffDisplay(MATCH.kickoffKst)} · Group {group} +
+
{MATCH.venue}
+
+
+ + {/* 국기 + 라이트닝 VS */} +
+ +
+
+ + VS + +
+ +
+
+
+ ); +} + +function KorFlag() { + // 실제 국기 이미지 + return ( + Korea Republic + ); +} + +function CzeFlag() { + // 체코 국기 (이미지 없음 → SVG) + return ( + + + + + + ); +} diff --git a/lib/format.ts b/lib/format.ts new file mode 100644 index 0000000..7eab126 --- /dev/null +++ b/lib/format.ts @@ -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); +} diff --git a/lib/mockData.ts b/lib/mockData.ts new file mode 100644 index 0000000..40ff97f --- /dev/null +++ b/lib/mockData.ts @@ -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"; diff --git a/lib/schedule.ts b/lib/schedule.ts new file mode 100644 index 0000000..f6af750 --- /dev/null +++ b/lib/schedule.ts @@ -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); diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..b194b88 --- /dev/null +++ b/lib/types.ts @@ -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; +} diff --git a/public/icons/ball.png b/public/icons/ball.png new file mode 100644 index 0000000..dd430ed Binary files /dev/null and b/public/icons/ball.png differ diff --git a/public/icons/claude.jpg b/public/icons/claude.jpg new file mode 100644 index 0000000..6c91bfa Binary files /dev/null and b/public/icons/claude.jpg differ diff --git a/public/icons/gemini.jpeg b/public/icons/gemini.jpeg new file mode 100644 index 0000000..faf8fc8 Binary files /dev/null and b/public/icons/gemini.jpeg differ diff --git a/public/icons/gpt.png b/public/icons/gpt.png new file mode 100644 index 0000000..89bde58 Binary files /dev/null and b/public/icons/gpt.png differ diff --git a/public/icons/kor.png b/public/icons/kor.png new file mode 100644 index 0000000..ce14df8 Binary files /dev/null and b/public/icons/kor.png differ diff --git a/public/icons/profile.png b/public/icons/profile.png new file mode 100644 index 0000000..c607df1 Binary files /dev/null and b/public/icons/profile.png differ