diff --git a/components/Leaderboard.tsx b/components/Leaderboard.tsx
new file mode 100644
index 0000000..f53d578
--- /dev/null
+++ b/components/Leaderboard.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { type Lang, dict } from "@/lib/i18n";
+import {
+ fetchLeaderboard,
+ type LeaderEntry,
+ type LeaderTab,
+} from "@/lib/leaderboard";
+
+// 누적 랭킹 — 참가자 / AI 모델 2탭, 폴딩(기본 접힘)
+// 매치 페이지(골드 카드 직후)에 삽입. defaultOpen=true 면 전체 페이지용 펼친 상태.
+export default function Leaderboard({
+ lang = "ko",
+ defaultOpen = false,
+ showAllLink = true,
+}: {
+ lang?: Lang;
+ defaultOpen?: boolean;
+ showAllLink?: boolean;
+}) {
+ const t = dict(lang);
+ const [open, setOpen] = useState(defaultOpen);
+ const [tab, setTab] = useState
("voters");
+ const [rows, setRows] = useState([]);
+
+ // 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
+ useEffect(() => {
+ if (!open) return;
+ let alive = true;
+ fetchLeaderboard(tab).then((r) => alive && setRows(r));
+ return () => {
+ alive = false;
+ };
+ }, [open, tab]);
+
+ return (
+
+
+
{t.lbTitle}
+ {showAllLink && (
+
+ {t.lbAll} →
+
+ )}
+
+
+ {open && (
+ <>
+ {/* 탭 */}
+
+ {(
+ [
+ ["voters", t.lbTabVoters],
+ ["ai", t.lbTabAI],
+ ] as [LeaderTab, string][]
+ ).map(([val, label]) => (
+
+ ))}
+
+
+ {/* 헤더 라벨 */}
+
+ {tab === "ai" ? t.lbTabAI : t.lbTabVoters}
+ {t.lbPoints}
+
+
+ {/* TOP 10 목록 */}
+
+ {rows.map((r, i) => {
+ const top3 = i < 3;
+ return (
+ -
+
+ {i + 1}
+
+
+ {r.label}
+
+
+ {r.points.toLocaleString()}
+
+ {t.lbUnit}
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+ {/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
+ {!defaultOpen && (
+
+ )}
+
+ );
+}
diff --git a/lib/i18n.ts b/lib/i18n.ts
index d3850f7..c478ad2 100644
--- a/lib/i18n.ts
+++ b/lib/i18n.ts
@@ -99,6 +99,14 @@ type Dict = {
thisMatch: string;
showMore: (n: number) => string;
showLess: string;
+ lbTitle: string;
+ lbTabVoters: string;
+ lbTabAI: string;
+ lbPoints: string;
+ lbUnit: string;
+ lbOpen: string;
+ lbClose: string;
+ lbAll: string;
};
export const DICT: Record = {
@@ -162,6 +170,14 @@ export const DICT: Record = {
thisMatch: "이번 경기",
showMore: (n) => `더보기 +${n}`,
showLess: "접기",
+ lbTitle: "누적 랭킹 TOP 10",
+ lbTabVoters: "참가자",
+ lbTabAI: "AI 모델",
+ lbPoints: "누적 포인트",
+ lbUnit: "P",
+ lbOpen: "랭킹 보기",
+ lbClose: "접기",
+ lbAll: "전체 보기",
adLabel: "광고",
ado2Tagline: "AI 마케팅 자동화 플랫폼",
ado2Sub: "AI Marketing Automation Platform →",
@@ -227,6 +243,14 @@ export const DICT: Record = {
thisMatch: "This match",
showMore: (n) => `Show ${n} more`,
showLess: "Show less",
+ lbTitle: "Cumulative Ranking · TOP 10",
+ lbTabVoters: "Players",
+ lbTabAI: "AI models",
+ lbPoints: "Points",
+ lbUnit: "P",
+ lbOpen: "View ranking",
+ lbClose: "Collapse",
+ lbAll: "View all",
adLabel: "AD",
ado2Tagline: "AI Marketing Automation Platform",
ado2Sub: "Visit ado2.o2osolution.ai →",
diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts
new file mode 100644
index 0000000..c70e5d7
--- /dev/null
+++ b/lib/leaderboard.ts
@@ -0,0 +1,56 @@
+// 누적 랭킹 — 투표 결과가 반영된 포인트 축적 TOP 10
+// ─────────────────────────────────────────────────────────────
+// UI(Leaderboard.tsx)는 fetchLeaderboard 의 "시그니처(입출력 타입)"에만 의존한다.
+// 백엔드 개발자는 fetchLeaderboard 의 *본문만* 콜러블 함수(getLeaderboard)
+// 호출로 교체하면 UI 수정 불필요.
+// const { fns } = getFirebase();
+// const res = await httpsCallable(fns, "getLeaderboard")({ tab });
+// return res.data as LeaderEntry[]; // 누적 포인트 내림차순, 최대 10
+//
+// 포인트 산정(서버 책임): 종료 경기마다 예측 정확도로 가점 → 누적.
+// PII: 참가자 label 은 *마스킹된 이메일 아이디*만 반환할 것(전체 이메일 금지).
+
+export type LeaderTab = "voters" | "ai";
+
+/** 랭킹 1행 — 표시용 2개 항목(이름/아이디 + 누적 포인트) */
+export type LeaderEntry = {
+ label: string; // 참가자: 마스킹된 이메일 아이디 / AI: 모델명(GPT·Claude·Gemini)
+ points: number; // 누적 포인트
+};
+
+/** 공개 노출용 이메일 아이디 마스킹: "haewonkam@x.com" → "hae****m" */
+export function maskEmailId(email: string): string {
+ const id = (email.split("@")[0] || email).trim();
+ if (id.length <= 2) return id[0] + "*";
+ if (id.length <= 4) return id.slice(0, 2) + "*".repeat(id.length - 2);
+ return id.slice(0, 3) + "*".repeat(Math.min(4, id.length - 4)) + id.slice(-1);
+}
+
+// ── 데모 목업 (결정론적) — 운영에서는 getLeaderboard 콜러블로 대체 ──
+const DEMO_VOTERS: LeaderEntry[] = [
+ { label: "cha***n", points: 1840 },
+ { label: "jun***0", points: 1715 },
+ { label: "min***k", points: 1660 },
+ { label: "soo***2", points: 1590 },
+ { label: "hae***i", points: 1505 },
+ { label: "yul***7", points: 1430 },
+ { label: "dae***n", points: 1388 },
+ { label: "seo***3", points: 1322 },
+ { label: "par***k", points: 1260 },
+ { label: "won***9", points: 1195 },
+];
+
+const DEMO_AI: LeaderEntry[] = [
+ { label: "Gemini", points: 1420 },
+ { label: "GPT", points: 1305 },
+ { label: "Claude", points: 1180 },
+];
+
+/* ============================================================
+ * 백엔드 연동 지점 — 누적 랭킹 TOP 10 조회
+ * 개발자: 본문을 getLeaderboard 콜러블 호출로 교체.
+ * ============================================================ */
+export async function fetchLeaderboard(tab: LeaderTab): Promise {
+ const list = tab === "ai" ? DEMO_AI : DEMO_VOTERS;
+ return [...list].sort((a, b) => b.points - a.points).slice(0, 10);
+}