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
parent
cbe0e8e01d
commit
b69b1dbe97
|
|
@ -21,7 +21,7 @@
|
||||||
--gold-btn-2: #15a99b; /* 틸그린 */
|
--gold-btn-2: #15a99b; /* 틸그린 */
|
||||||
|
|
||||||
--gpt: #15a99b; /* 틸그린 (teal green) */
|
--gpt: #15a99b; /* 틸그린 (teal green) */
|
||||||
--claude: #e8814a;
|
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
|
||||||
--gemini: #5b8cff;
|
--gemini: #5b8cff;
|
||||||
|
|
||||||
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ import type {
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import Countdown from "./Countdown";
|
import Countdown from "./Countdown";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
import {
|
||||||
|
fetchMyPredictions,
|
||||||
|
saveMyPrediction,
|
||||||
|
rememberEmail,
|
||||||
|
recallEmail,
|
||||||
|
type MyPrediction,
|
||||||
|
} from "@/lib/myPredictions";
|
||||||
|
|
||||||
type Step = "form" | "done";
|
type Step = "form" | "done";
|
||||||
|
|
||||||
|
|
@ -25,6 +32,12 @@ const MODEL_ICON: Record<ModelName, string> = {
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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({
|
export default function Arena({
|
||||||
match,
|
match,
|
||||||
predictions,
|
predictions,
|
||||||
|
|
@ -51,6 +64,34 @@ export default function Arena({
|
||||||
const [notify, setNotify] = useState(true);
|
const [notify, setNotify] = useState(true);
|
||||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||||
const [copied, setCopied] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -81,15 +122,31 @@ export default function Arena({
|
||||||
}, [outcome, scoreA, scoreB, predictions]);
|
}, [outcome, scoreA, scoreB, predictions]);
|
||||||
|
|
||||||
const emailValid = EMAIL_RE.test(email.trim());
|
const emailValid = EMAIL_RE.test(email.trim());
|
||||||
const confirmSubmit = () => {
|
const confirmSubmit = async () => {
|
||||||
if (!EMAIL_RE.test(email.trim())) return;
|
if (!emailValid || !outcome) return;
|
||||||
setCrowd((c) => ({
|
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
|
||||||
...c,
|
if (!myThisMatch) {
|
||||||
total: c.total + 1,
|
setCrowd((c) => ({
|
||||||
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
|
...c,
|
||||||
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
|
total: c.total + 1,
|
||||||
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
|
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");
|
setStep("done");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -280,7 +337,7 @@ export default function Arena({
|
||||||
type="email"
|
type="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
placeholder={t.emailPh}
|
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)]"
|
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>
|
</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) ===== */}
|
{/* ===== 골드 상금 (003) ===== */}
|
||||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|
|
||||||
15
lib/i18n.ts
15
lib/i18n.ts
|
|
@ -94,6 +94,11 @@ type Dict = {
|
||||||
ado2Cta: string;
|
ado2Cta: string;
|
||||||
genLabel: string;
|
genLabel: string;
|
||||||
moreMatches: string;
|
moreMatches: string;
|
||||||
|
myVotesTitle: string;
|
||||||
|
myVotesSub: string;
|
||||||
|
thisMatch: string;
|
||||||
|
showMore: (n: number) => string;
|
||||||
|
showLess: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DICT: Record<Lang, Dict> = {
|
export const DICT: Record<Lang, Dict> = {
|
||||||
|
|
@ -152,6 +157,11 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
||||||
genLabel: "예측 기준",
|
genLabel: "예측 기준",
|
||||||
moreMatches: "다른 경기 투표하기",
|
moreMatches: "다른 경기 투표하기",
|
||||||
|
myVotesTitle: "내 지난 예측",
|
||||||
|
myVotesSub: "이메일 기준 · 최신순",
|
||||||
|
thisMatch: "이번 경기",
|
||||||
|
showMore: (n) => `더보기 +${n}`,
|
||||||
|
showLess: "접기",
|
||||||
adLabel: "광고",
|
adLabel: "광고",
|
||||||
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
||||||
ado2Sub: "AI Marketing Automation Platform →",
|
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}`,
|
hook: (a, b) => `AI is split on ${a} vs ${b}`,
|
||||||
genLabel: "As of",
|
genLabel: "As of",
|
||||||
moreMatches: "Vote on another match",
|
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",
|
adLabel: "AD",
|
||||||
ado2Tagline: "AI Marketing Automation Platform",
|
ado2Tagline: "AI Marketing Automation Platform",
|
||||||
ado2Sub: "Visit ado2.o2osolution.ai →",
|
ado2Sub: "Visit ado2.o2osolution.ai →",
|
||||||
|
|
|
||||||
|
|
@ -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) || "";
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue