diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 6c1631c..713c439 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -1,25 +1,31 @@ import type { Metadata } from "next"; import Hero from "@/components/Hero"; import Footer from "@/components/Footer"; +import Leaderboard from "@/components/Leaderboard"; +import { parseLang, dict } from "@/lib/i18n"; -// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub. export const metadata: Metadata = { - title: "랭킹 | TriplePick 2026", - description: "TriplePick 누적 랭킹 — 곧 공개됩니다.", + title: "누적 랭킹 | TriplePick 2026", + description: "TriplePick 누적 랭킹 — 참가자·AI 모델 TOP 10.", }; -export default function LeaderboardPage() { +export default async function LeaderboardPage({ + searchParams, +}: { + searchParams: Promise<{ lang?: string }>; +}) { + const { lang: langParam } = await searchParams; + const lang = parseLang(langParam); + const t = dict(lang); + return (
-
-
LEADERBOARD
-

랭킹은 곧 공개됩니다

-

- 매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 - 최종 100만원을 드립니다. -

-
+

+ {t.goldDesc} +

+ {/* 전체 페이지: 펼친 상태(폴딩 토글·전체보기 링크 숨김) */} +
); diff --git a/components/Arena.tsx b/components/Arena.tsx index 4a7c7c2..87f696c 100644 --- a/components/Arena.tsx +++ b/components/Arena.tsx @@ -14,6 +14,7 @@ import type { } from "@/lib/types"; import Countdown from "./Countdown"; import TeamFlag from "./TeamFlag"; +import Leaderboard from "./Leaderboard"; import { fetchMyPredictions, saveMyPrediction, @@ -476,6 +477,9 @@ export default function Arena({ + {/* ===== 누적 랭킹 TOP 10 (골드 카드 직후 · 폴딩) ===== */} + + {/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
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 ( +
  1. + + {i + 1} + + + {r.label} + + + {r.points.toLocaleString()} + + {t.lbUnit} + + +
  2. + ); + })} +
+ + )} + + {/* 폴딩 토글 — 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); +}