Add "my past predictions" (email-based, no login) + cleanup

- 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 <noreply@anthropic.com>
main
Haewon Kam 2026-06-18 14:32:17 +09:00
parent cbe0e8e01d
commit b69b1dbe97
4 changed files with 237 additions and 11 deletions

View File

@ -21,7 +21,7 @@
--gold-btn-2: #15a99b; /* 틸그린 */
--gpt: #15a99b; /* 틸그린 (teal green) */
--claude: #e8814a;
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
--gemini: #5b8cff;
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */

View File

@ -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<ModelName, string> = {
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<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
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,8 +122,10 @@ export default function Arena({
}, [outcome, scoreA, scoreB, predictions]);
const emailValid = EMAIL_RE.test(email.trim());
const confirmSubmit = () => {
if (!EMAIL_RE.test(email.trim())) return;
const confirmSubmit = async () => {
if (!emailValid || !outcome) return;
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
if (!myThisMatch) {
setCrowd((c) => ({
...c,
total: c.total + 1,
@ -90,6 +133,20 @@ export default function Arena({
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({
</div>
)}
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
{myPreds.length > 0 && (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<div className="mb-3 flex items-baseline justify-between gap-2">
<h3 className="text-[16px] font-extrabold">{t.myVotesTitle}</h3>
<span className="shrink-0 text-[12px] text-[var(--ink-muted)]">
{t.myVotesSub}
</span>
</div>
<ul className="flex flex-col gap-2">
{(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 (
<li
key={p.matchId}
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
isThis
? "border-[var(--share)] bg-[rgba(166,94,255,0.1)]"
: "border-[var(--line-d)]"
}`}
>
<div className="min-w-0">
<div className="text-[15px] font-bold text-white">
{p.teamAShort} {p.scoreA}-{p.scoreB} {p.teamBShort}
</div>
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">
{pickTxt}
{isThis && (
<span className="text-[var(--share)]"> · {t.thisMatch}</span>
)}
</div>
</div>
{p.result ? (
<span
className={`shrink-0 text-[13px] font-bold ${
p.result.hitOutcome
? "text-[var(--share)]"
: "text-[var(--ink-muted)]"
}`}
>
{p.result.hitOutcome ? t.hit : t.miss}
</span>
) : (
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">
{fmtSubmitted(p.submittedAt)}
</span>
)}
</li>
);
})}
</ul>
{myPreds.length > 1 && (
<button
onClick={() => setVotesExpanded((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
>
{votesExpanded ? t.showLess : t.showMore(myPreds.length - 1)}
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
</button>
)}
</section>
)}
{/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">

View File

@ -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<Lang, Dict> = {
@ -152,6 +157,11 @@ export const DICT: Record<Lang, Dict> = {
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<Lang, Dict> = {
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 →",

85
lib/myPredictions.ts Normal file
View File

@ -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<string, MyPrediction[]> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(window.localStorage.getItem(STORE_KEY) || "{}");
} catch {
return {};
}
}
function writeStore(store: Record<string, MyPrediction[]>) {
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<MyPrediction[]> {
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<void> {
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) || "";
}