From b69b1dbe97f440db453c01276a0d63040e6df83e Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 18 Jun 2026 14:32:17 +0900 Subject: [PATCH] Add "my past predictions" (email-based, no login) + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/myPredictions.ts: typed contract (fetch/save/remember/recall) with localStorage demo impl; backend dev swaps the two bodies for Firebase callables (getMyPredictions / submitPrediction) - Arena: recall email on revisit, show my picks sorted newest-first, prefill + highlight the current match, upsert per match - Fold list to 1 row by default with 더보기/접기 toggle (handles 10s of votes) - Result badges: hit = purple (--share), miss = gray; date until scored - Remove unused orange --claude token (orange-ban rule; logo image stays) Co-Authored-By: Claude Opus 4.8 --- app/globals.css | 2 +- components/Arena.tsx | 146 ++++++++++++++++++++++++++++++++++++++++--- lib/i18n.ts | 15 +++++ lib/myPredictions.ts | 85 +++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 lib/myPredictions.ts diff --git a/app/globals.css b/app/globals.css index 72b874a..0161646 100644 --- a/app/globals.css +++ b/app/globals.css @@ -21,7 +21,7 @@ --gold-btn-2: #15a99b; /* 틸그린 */ --gpt: #15a99b; /* 틸그린 (teal green) */ - --claude: #e8814a; + /* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */ --gemini: #5b8cff; --share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */ diff --git a/components/Arena.tsx b/components/Arena.tsx index 9c67895..4a7c7c2 100644 --- a/components/Arena.tsx +++ b/components/Arena.tsx @@ -14,6 +14,13 @@ import type { } from "@/lib/types"; import Countdown from "./Countdown"; import TeamFlag from "./TeamFlag"; +import { + fetchMyPredictions, + saveMyPrediction, + rememberEmail, + recallEmail, + type MyPrediction, +} from "@/lib/myPredictions"; type Step = "form" | "done"; @@ -25,6 +32,12 @@ const MODEL_ICON: Record = { const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨) +function fmtSubmitted(iso: string): string { + const m = iso.match(/\d{4}-(\d{2})-(\d{2})/); + return m ? `${+m[1]}.${+m[2]}` : ""; +} + export default function Arena({ match, predictions, @@ -51,6 +64,34 @@ export default function Arena({ const [notify, setNotify] = useState(true); const [crowd, setCrowd] = useState(initialCrowd); const [copied, setCopied] = useState(false); + const [myPreds, setMyPreds] = useState([]); + const [votesExpanded, setVotesExpanded] = useState(false); + + // 재방문 시: 기억된 이메일 복원 → 내 지난 예측 로드 (로그인 대체) + useEffect(() => { + const saved = recallEmail(); + if (!saved) return; + setEmail(saved); + fetchMyPredictions(saved).then(setMyPreds); + }, []); + + // 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 강조) + const myThisMatch = useMemo( + () => myPreds.find((p) => p.matchId === match.matchId), + [myPreds, match.matchId], + ); + useEffect(() => { + if (myThisMatch) { + setScoreA(myThisMatch.scoreA); + setScoreB(myThisMatch.scoreB); + } + }, [myThisMatch]); + + // 이메일 입력이 유효해지면 그 즉시 내 기록 조회(다른 기기/세션 흔적 표시) + const onEmailChange = (v: string) => { + setEmail(v); + if (EMAIL_RE.test(v.trim())) fetchMyPredictions(v).then(setMyPreds); + }; // 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스) useEffect(() => { @@ -81,15 +122,31 @@ export default function Arena({ }, [outcome, scoreA, scoreB, predictions]); const emailValid = EMAIL_RE.test(email.trim()); - const confirmSubmit = () => { - if (!EMAIL_RE.test(email.trim())) 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), - })); + const confirmSubmit = async () => { + if (!emailValid || !outcome) return; + // 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지) + if (!myThisMatch) { + 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), + })); + } + // 내 예측 저장(경기당 1건 upsert) + 이메일 기억 → 재방문 자동 복원 + await saveMyPrediction(email, { + matchId: match.matchId, + teamAShort: aShort, + teamBShort: bShort, + kickoffKst: match.kickoffKst, + outcome, + scoreA, + scoreB, + submittedAt: new Date().toISOString(), + }); + rememberEmail(email); + setMyPreds(await fetchMyPredictions(email)); setStep("done"); }; @@ -280,7 +337,7 @@ export default function Arena({ type="email" inputMode="email" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => onEmailChange(e.target.value)} placeholder={t.emailPh} className="w-full rounded-2xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]" /> @@ -332,6 +389,75 @@ export default function Arena({ )} + {/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */} + {myPreds.length > 0 && ( +
+
+

{t.myVotesTitle}

+ + {t.myVotesSub} + +
+
    + {(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => { + const isThis = p.matchId === match.matchId; + const pickTxt = + p.outcome === "TEAM_A_WIN" + ? `${p.teamAShort} ${t.win}` + : p.outcome === "TEAM_B_WIN" + ? `${p.teamBShort} ${t.win}` + : t.drawLabel; + return ( +
  • +
    +
    + {p.teamAShort} {p.scoreA}-{p.scoreB} {p.teamBShort} +
    +
    + {pickTxt} + {isThis && ( + · {t.thisMatch} + )} +
    +
    + {p.result ? ( + + {p.result.hitOutcome ? t.hit : t.miss} + + ) : ( + + {fmtSubmitted(p.submittedAt)} + + )} +
  • + ); + })} +
+ {myPreds.length > 1 && ( + + )} +
+ )} + {/* ===== 골드 상금 (003) ===== */}
diff --git a/lib/i18n.ts b/lib/i18n.ts index 670ba70..d3850f7 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -94,6 +94,11 @@ type Dict = { ado2Cta: string; genLabel: string; moreMatches: string; + myVotesTitle: string; + myVotesSub: string; + thisMatch: string; + showMore: (n: number) => string; + showLess: string; }; export const DICT: Record = { @@ -152,6 +157,11 @@ export const DICT: Record = { hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`, genLabel: "예측 기준", moreMatches: "다른 경기 투표하기", + myVotesTitle: "내 지난 예측", + myVotesSub: "이메일 기준 · 최신순", + thisMatch: "이번 경기", + showMore: (n) => `더보기 +${n}`, + showLess: "접기", adLabel: "광고", ado2Tagline: "AI 마케팅 자동화 플랫폼", ado2Sub: "AI Marketing Automation Platform →", @@ -212,6 +222,11 @@ export const DICT: Record = { hook: (a, b) => `AI is split on ${a} vs ${b}`, genLabel: "As of", moreMatches: "Vote on another match", + myVotesTitle: "Your past predictions", + myVotesSub: "By email · newest first", + thisMatch: "This match", + showMore: (n) => `Show ${n} more`, + showLess: "Show less", adLabel: "AD", ado2Tagline: "AI Marketing Automation Platform", ado2Sub: "Visit ado2.o2osolution.ai →", diff --git a/lib/myPredictions.ts b/lib/myPredictions.ts new file mode 100644 index 0000000..307368b --- /dev/null +++ b/lib/myPredictions.ts @@ -0,0 +1,85 @@ +// 내 지난 예측 — 로그인 없이 이메일 기준 조회/저장 +// ───────────────────────────────────────────────────────────── +// UI(Arena.tsx)는 아래 4개 함수의 "시그니처(입출력 타입)"에만 의존한다. +// 백엔드 개발자는 fetchMyPredictions / saveMyPrediction 의 *본문만* +// Firebase 콜러블 함수(getMyPredictions / submitPrediction) 호출로 교체하면 +// UI 코드는 수정할 필요가 없다. (rememberEmail/recallEmail 은 그대로 클라 보관) +// +// 현재 구현: 데모용 localStorage (기기 1대 기준). 운영에서는 Firestore +// user_predictions 를 이메일로 조회하는 콜러블로 대체 → 기기 간 동기화. +import type { Outcome } from "./types"; + +/** 이메일 기준으로 조회되는 1건의 사용자 예측 (표시·정렬용 필드 포함) */ +export type MyPrediction = { + matchId: string; + teamAShort: string; // "멕시코" + teamBShort: string; // "한국" + kickoffKst: string; // ISO — 정렬/표시용 + outcome: Outcome; + scoreA: number; + scoreB: number; + submittedAt: string; // ISO — 제출 시각(최신순 정렬 기준) + /** 경기 종료 시 백엔드가 채움. 미종료면 undefined */ + result?: { outcome: Outcome; scoreA: number; scoreB: number; hitOutcome: boolean }; +}; + +const STORE_KEY = "tp_my_predictions_v1"; // { [emailLower]: MyPrediction[] } +const EMAIL_KEY = "tp_email_v1"; + +const normEmail = (email: string) => email.trim().toLowerCase(); + +function readStore(): Record { + if (typeof window === "undefined") return {}; + try { + return JSON.parse(window.localStorage.getItem(STORE_KEY) || "{}"); + } catch { + return {}; + } +} + +function writeStore(store: Record) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORE_KEY, JSON.stringify(store)); +} + +/* ============================================================ + * 백엔드 연동 지점 ① — 내 예측 목록 조회 + * 개발자: 본문을 getMyPredictions 콜러블 호출로 교체. + * const { fns } = getFirebase(); + * const res = await httpsCallable(fns, "getMyPredictions")({ email }); + * return res.data as MyPrediction[]; // 최신순 정렬 보장 + * ============================================================ */ +export async function fetchMyPredictions(email: string): Promise { + if (!email) return []; + const list = readStore()[normEmail(email)] ?? []; + // 최신 제출순(내림차순) + return [...list].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); +} + +/* ============================================================ + * 백엔드 연동 지점 ② — 내 예측 제출/갱신 + * 개발자: 본문을 submitPrediction 콜러블 호출로 교체(서버에서 crowd_stats + * 원자적 증분 + PII 보호). 경기당 1건(matchId 기준 upsert). + * ============================================================ */ +export async function saveMyPrediction(email: string, rec: MyPrediction): Promise { + if (!email) return; + const key = normEmail(email); + const store = readStore(); + const list = store[key] ?? []; + const next = list.filter((p) => p.matchId !== rec.matchId); // 같은 경기는 최신 픽으로 교체 + next.push(rec); + store[key] = next; + writeStore(store); +} + +/** 마지막 사용 이메일 기억 (재방문 시 자동 복원 — 로그인 대체) */ +export function rememberEmail(email: string) { + if (typeof window === "undefined" || !email) return; + window.localStorage.setItem(EMAIL_KEY, normEmail(email)); +} + +/** 기억된 이메일 불러오기 (없으면 "") */ +export function recallEmail(): string { + if (typeof window === "undefined") return ""; + return window.localStorage.getItem(EMAIL_KEY) || ""; +}