diff --git a/components/Arena.tsx b/components/Arena.tsx index 10cb619..a4417ba 100644 --- a/components/Arena.tsx +++ b/components/Arena.tsx @@ -1,8 +1,8 @@ "use client"; import { useMemo, useState } from "react"; -import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL } from "@/lib/mockData"; -import { outcomeLabel, pct } from "@/lib/format"; +import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL, DEMO_FORCE_OPEN } from "@/lib/mockData"; +import { outcomeLabel, pct, kickoffDisplay } from "@/lib/format"; import type { Outcome, CrowdStats, ModelName } from "@/lib/types"; type Step = "pick" | "form" | "done"; @@ -29,7 +29,16 @@ export default function Arena() { const [crowd, setCrowd] = useState(CROWD); const [copied, setCopied] = useState(false); - const locked = Date.now() >= new Date(MATCH.lockAt).getTime(); + // 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각) + const now = Date.now(); + const notOpen = !DEMO_FORCE_OPEN && now < new Date(MATCH.opensAt).getTime(); + const locked = now >= new Date(MATCH.lockAt).getTime(); + const disabled = locked || notOpen; // 폼 비활성 + const ctaLabel = notOpen + ? `투표 오픈 ${kickoffDisplay(MATCH.opensAt)}` + : locked + ? "예측 마감 (경기 시작)" + : "AI보다 잘 맞히기"; const matched = useMemo(() => { if (!outcome) return []; @@ -156,7 +165,7 @@ export default function Arena() { ).map(([val, label]) => ( )} diff --git a/docs/BACKEND.md b/docs/BACKEND.md index c53de61..04ad48d 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -10,6 +10,10 @@ - **Firebase = 사용자가 새 프로젝트 직접 생성** → config 6키·서비스계정 추후 제공 - **상금 챌린지(약관) = R2로 연기** → R1 백엔드는 픽 저장·채점·Crowd·이메일 알림까지만. `event_participation` 로직 보류 - **결과 알림 = 이메일(Resend)** +- **투표 창 = 오픈 킥오프 D-2(−48h) · 마감 킥오프 정각(경기 시작 전까지)** + - 채점·상금 대상 픽은 경기 시작 전에 닫는다(블라인드 예측·AI vs 유저 동일 출발선·사행성 회피). 하프타임 등 경기 중 투표는 채택 안 함. + - 필드: `matches.opensAt`(=킥오프−48h), `matches.lockAt`(=킥오프). 상태: scheduled→open→locked→live→finished. + - 프론트 데모용 `DEMO_FORCE_OPEN`(mockData) — 운영 배포 시 false. ## 1. R1 백엔드 범위 (이번에 만들 것) 1. 픽 제출 저장 (검증·중복방지·마감) — 콜러블 함수 `submitPrediction` @@ -47,7 +51,7 @@ Next.js (Vercel) ──read──> Firestore (공개: matches/ai_predictions/c { "ok":true,"predictionId":"A_KOR_CZE_20260612_uuid","matchedModels":["GPT"], "exactMatch":false,"crowd":{"total":1285,"teamAWin":616,"draw":347,"teamBWin":322} } ``` -검증/에러: `MATCH_NOT_FOUND` · `MATCH_LOCKED`(now ≥ lockAt) · `INVALID_OUTCOME` · `INVALID_SCORE`(0–9 int) · `NICKNAME_INVALID`(2–16자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내) +검증/에러: `MATCH_NOT_FOUND` · `MATCH_NOT_OPEN`(now < opensAt) · `MATCH_LOCKED`(now ≥ lockAt = 킥오프) · `INVALID_OUTCOME` · `INVALID_SCORE`(0–9 int) · `NICKNAME_INVALID`(2–16자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내) 처리: 트랜잭션으로 ① docId=`matchId_deviceId` 없으면 create ② crowd_stats 증분 ③ matchedModels 계산. ### 3.2 updatePrediction (callable) diff --git a/firestore.rules b/firestore.rules index 7c978fb..910b311 100644 --- a/firestore.rules +++ b/firestore.rules @@ -38,6 +38,6 @@ service cloud.firestore { } // 참고: 클라이언트 직접 쓰기 방식을 택할 경우(함수 없이), user_predictions에 -// create 검증 규칙(킥오프 -10분 lock, outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN}, +// create 검증 규칙(opens_at ≤ now < lock_at[=킥오프 정각], outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN}, // score 0~9 정수, nickname 2~16자, docId == matchId+"_"+deviceId)을 넣어야 한다. // 단 crowd_stats 원자적 증분과 PII 보호 때문에 콜러블 함수 방식을 권장한다(docs/BACKEND.md). diff --git a/lib/mockData.ts b/lib/mockData.ts index 40ff97f..f6a801c 100644 --- a/lib/mockData.ts +++ b/lib/mockData.ts @@ -6,6 +6,10 @@ import { FEATURED } from "./schedule"; export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT) +// 데모용: 실제 운영은 투표 오픈 = 킥오프 D-2(opensAt). 팀 데모에서 픽이 막히지 않게 +// true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로. +export const DEMO_FORCE_OPEN = true; + export const AI_PREDICTIONS: AIPrediction[] = [ { matchId: MATCH.matchId, diff --git a/lib/schedule.ts b/lib/schedule.ts index f6af750..28c990a 100644 --- a/lib/schedule.ts +++ b/lib/schedule.ts @@ -7,8 +7,9 @@ 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(); +// 투표 오픈 = 킥오프 -48시간 (D-2). 마감 = 킥오프 정각(경기 시작 전까지). +function opens(iso: string): string { + return new Date(new Date(iso).getTime() - 48 * 3600000).toISOString(); } // Group A 전체 6경기 — 경기별 정확 일시 자동 반영용 SSOT @@ -21,7 +22,8 @@ export const GROUP_A: Match[] = [ teamB: RSA, kickoffKst: "2026-06-12T04:00:00+09:00", venue: "Estadio Azteca · Mexico City", - lockAt: lock("2026-06-12T04:00:00+09:00"), + opensAt: opens("2026-06-12T04:00:00+09:00"), + lockAt: "2026-06-12T04:00:00+09:00", status: "upcoming", hookText: "월드컵 개막전, AI는 개최국을 믿을까", result: null, @@ -34,7 +36,8 @@ export const GROUP_A: Match[] = [ teamB: CZE, kickoffKst: "2026-06-12T11:00:00+09:00", venue: "Estadio Guadalajara (Akron)", - lockAt: lock("2026-06-12T11:00:00+09:00"), + opensAt: opens("2026-06-12T11:00:00+09:00"), + lockAt: "2026-06-12T11:00:00+09:00", status: "upcoming", hookText: "한국 첫 경기, AI의 선택은 갈렸다", result: null, @@ -47,7 +50,8 @@ export const GROUP_A: Match[] = [ teamB: RSA, kickoffKst: "2026-06-19T01:00:00+09:00", venue: "USA (TBD)", - lockAt: lock("2026-06-19T01:00:00+09:00"), + opensAt: opens("2026-06-19T01:00:00+09:00"), + lockAt: "2026-06-19T01:00:00+09:00", status: "upcoming", hookText: "체코 vs 남아공, AI의 예측은", result: null, @@ -60,7 +64,8 @@ export const GROUP_A: Match[] = [ teamB: KOR, kickoffKst: "2026-06-19T10:00:00+09:00", venue: "Estadio Guadalajara (Akron)", - lockAt: lock("2026-06-19T10:00:00+09:00"), + opensAt: opens("2026-06-19T10:00:00+09:00"), + lockAt: "2026-06-19T10:00:00+09:00", status: "upcoming", hookText: "개최국 멕시코 vs 한국, AI는 누구 편", result: null, @@ -73,7 +78,8 @@ export const GROUP_A: Match[] = [ teamB: MEX, kickoffKst: "2026-06-25T10:00:00+09:00", venue: "Estadio Azteca · Mexico City", - lockAt: lock("2026-06-25T10:00:00+09:00"), + opensAt: opens("2026-06-25T10:00:00+09:00"), + lockAt: "2026-06-25T10:00:00+09:00", status: "upcoming", hookText: "체코 vs 멕시코, 운명의 최종전", result: null, @@ -86,7 +92,8 @@ export const GROUP_A: Match[] = [ teamB: KOR, kickoffKst: "2026-06-25T10:00:00+09:00", venue: "Estadio BBVA · Monterrey", - lockAt: lock("2026-06-25T10:00:00+09:00"), + opensAt: opens("2026-06-25T10:00:00+09:00"), + lockAt: "2026-06-25T10:00:00+09:00", status: "upcoming", hookText: "한국 16강의 갈림길, AI의 마지막 예측", result: null, diff --git a/lib/types.ts b/lib/types.ts index b194b88..d8fb2e3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3,7 +3,14 @@ export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN"; export type ModelName = "GPT" | "Claude" | "Gemini"; -export type MatchStatus = "upcoming" | "locked" | "live" | "finished"; +// scheduled: 오픈 전 / open: 투표 가능 / locked: 킥오프 후 마감 / live / finished +export type MatchStatus = + | "scheduled" + | "open" + | "locked" + | "live" + | "finished" + | "upcoming"; export interface Team { name: string; // 표시명 (예: Korea Republic) @@ -20,7 +27,8 @@ export interface Match { teamB: Team; kickoffKst: string; // ISO with +09:00 venue: string; - lockAt: string; // ISO (킥오프 -10분) + opensAt: string; // ISO — 투표 오픈 (킥오프 -48h, D-2) + lockAt: string; // ISO — 투표 마감 (킥오프 정각 = 경기 시작 전까지) status: MatchStatus; hookText: string; // "한국 첫 경기, AI의 선택은 갈렸다" result?: { scoreA: number; scoreB: number; outcome: Outcome } | null; diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 19a3711..b7b5cac 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -8,7 +8,8 @@ import { getFirestore, FieldValue } from "firebase-admin/firestore"; initializeApp({ credential: applicationDefault() }); const db = getFirestore(); -const lock = (iso) => new Date(new Date(iso).getTime() - 10 * 60000).toISOString(); +// 투표 오픈 = 킥오프 -48h(D-2), 마감 = 킥오프 정각(경기 시작 전까지) +const opens = (iso) => new Date(new Date(iso).getTime() - 48 * 3600000).toISOString(); const T = { KOR: { name: "Korea Republic", shortName: "한국", code: "KOR", flag: "🇰🇷" }, CZE: { name: "Czechia", shortName: "체코", code: "CZE", flag: "🇨🇿" }, @@ -39,8 +40,9 @@ async function run() { for (const [matchId, round, a, b, kickoff, venue, hook] of MATCHES) { batch.set(db.collection("matches").doc(matchId), { matchId, roundLabel: round, group: "A", teamA: a, teamB: b, - kickoffKst: kickoff, venue, lockAt: lock(kickoff), status: "upcoming", - hookText: hook, result: null, updatedAt: FieldValue.serverTimestamp(), + kickoffKst: kickoff, venue, opensAt: opens(kickoff), lockAt: kickoff, + status: "scheduled", hookText: hook, result: null, + updatedAt: FieldValue.serverTimestamp(), }); // crowd_stats 초기화 (0) batch.set(db.collection("crowd_stats").doc(matchId), {