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; /* 틸그린 */
|
||||
|
||||
--gpt: #15a99b; /* 틸그린 (teal green) */
|
||||
--claude: #e8814a;
|
||||
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
|
||||
--gemini: #5b8cff;
|
||||
|
||||
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
||||
|
|
|
|||
|
|
@ -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,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({
|
|||
</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">
|
||||
|
|
|
|||
15
lib/i18n.ts
15
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<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 →",
|
||||
|
|
|
|||
|
|
@ -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