Add cumulative ranking (TOP 10, voters/AI tabs, folding)

- New lib/leaderboard.ts: fetchLeaderboard(tab) contract + maskEmailId
  (PII) + deterministic demo; backend swaps body for getLeaderboard
- New components/Leaderboard.tsx: 2 tabs (참가자/AI 모델), folded by
  default with 랭킹 보기/접기, top-3 purple highlight, id + points
- Place folding ranking right after the gold ₩1M card on match page
- /leaderboard full page renders the same component expanded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Haewon Kam 2026-06-18 16:32:26 +09:00
parent b69b1dbe97
commit 965bd374c3
5 changed files with 232 additions and 12 deletions

View File

@ -1,25 +1,31 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Leaderboard from "@/components/Leaderboard";
import { parseLang, dict } from "@/lib/i18n";
// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub.
export const metadata: Metadata = { export const metadata: Metadata = {
title: "랭킹 | TriplePick 2026", title: "누적 랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 곧 공개됩니다.", 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 ( return (
<main className="shell"> <main className="shell">
<Hero back /> <Hero back />
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center"> <p className="mt-6 text-center text-[13px] leading-relaxed text-[var(--ink-muted)]">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div> {t.goldDesc}
<p className="mt-3 text-[15px] font-bold"> </p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
,
100 .
</p> </p>
</section> {/* 전체 페이지: 펼친 상태(폴딩 토글·전체보기 링크 숨김) */}
<Leaderboard lang={lang} defaultOpen showAllLink={false} />
<Footer /> <Footer />
</main> </main>
); );

View File

@ -14,6 +14,7 @@ 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 Leaderboard from "./Leaderboard";
import { import {
fetchMyPredictions, fetchMyPredictions,
saveMyPrediction, saveMyPrediction,
@ -476,6 +477,9 @@ export default function Arena({
</div> </div>
</section> </section>
{/* ===== 누적 랭킹 TOP 10 (골드 카드 직후 · 폴딩) ===== */}
<Leaderboard lang={lang} />
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */} {/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
<section className="mt-6"> <section className="mt-6">
<div className="mb-2.5 text-[20px] font-extrabold"> <div className="mb-2.5 text-[20px] font-extrabold">

130
components/Leaderboard.tsx Normal file
View File

@ -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<LeaderTab>("voters");
const [rows, setRows] = useState<LeaderEntry[]>([]);
// 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
useEffect(() => {
if (!open) return;
let alive = true;
fetchLeaderboard(tab).then((r) => alive && setRows(r));
return () => {
alive = false;
};
}, [open, tab]);
return (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-[16px] font-extrabold">{t.lbTitle}</h3>
{showAllLink && (
<Link
href={lang === "en" ? "/leaderboard?lang=en" : "/leaderboard"}
className="shrink-0 text-[12px] font-bold text-[var(--share)]"
>
{t.lbAll}
</Link>
)}
</div>
{open && (
<>
{/* 탭 */}
<div className="mt-3 grid grid-cols-2 gap-2">
{(
[
["voters", t.lbTabVoters],
["ai", t.lbTabAI],
] as [LeaderTab, string][]
).map(([val, label]) => (
<button
key={val}
onClick={() => setTab(val)}
className={`rounded-xl py-2.5 text-[14px] font-extrabold transition ${
tab === val
? "bg-[var(--share)] text-white"
: "border border-[var(--line-d)] text-[var(--ink-muted)]"
}`}
>
{label}
</button>
))}
</div>
{/* 헤더 라벨 */}
<div className="mt-3 flex items-center justify-between px-3 text-[11px] font-bold text-[var(--ink-muted)]">
<span>{tab === "ai" ? t.lbTabAI : t.lbTabVoters}</span>
<span>{t.lbPoints}</span>
</div>
{/* TOP 10 목록 */}
<ol className="mt-1.5 flex flex-col gap-1.5">
{rows.map((r, i) => {
const top3 = i < 3;
return (
<li
key={`${r.label}-${i}`}
className={`flex items-center gap-3 rounded-xl border px-3 py-2.5 ${
top3
? "border-[var(--share)]/50 bg-[rgba(166,94,255,0.08)]"
: "border-[var(--line-d)]"
}`}
>
<span
className={`w-6 shrink-0 text-center font-mono text-[15px] font-extrabold tabular-nums ${
top3 ? "text-[var(--share)]" : "text-[var(--ink-muted)]"
}`}
>
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-[15px] font-bold text-white">
{r.label}
</span>
<span className="shrink-0 font-mono text-[15px] font-extrabold tabular-nums text-white">
{r.points.toLocaleString()}
<span className="ml-0.5 text-[12px] text-[var(--ink-muted)]">
{t.lbUnit}
</span>
</span>
</li>
);
})}
</ol>
</>
)}
{/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
{!defaultOpen && (
<button
onClick={() => setOpen((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]"
>
{open ? t.lbClose : t.lbOpen}
<span aria-hidden>{open ? "▲" : "▼"}</span>
</button>
)}
</section>
);
}

View File

@ -99,6 +99,14 @@ type Dict = {
thisMatch: string; thisMatch: string;
showMore: (n: number) => string; showMore: (n: number) => string;
showLess: string; showLess: string;
lbTitle: string;
lbTabVoters: string;
lbTabAI: string;
lbPoints: string;
lbUnit: string;
lbOpen: string;
lbClose: string;
lbAll: string;
}; };
export const DICT: Record<Lang, Dict> = { export const DICT: Record<Lang, Dict> = {
@ -162,6 +170,14 @@ export const DICT: Record<Lang, Dict> = {
thisMatch: "이번 경기", thisMatch: "이번 경기",
showMore: (n) => `더보기 +${n}`, showMore: (n) => `더보기 +${n}`,
showLess: "접기", showLess: "접기",
lbTitle: "누적 랭킹 TOP 10",
lbTabVoters: "참가자",
lbTabAI: "AI 모델",
lbPoints: "누적 포인트",
lbUnit: "P",
lbOpen: "랭킹 보기",
lbClose: "접기",
lbAll: "전체 보기",
adLabel: "광고", adLabel: "광고",
ado2Tagline: "AI 마케팅 자동화 플랫폼", ado2Tagline: "AI 마케팅 자동화 플랫폼",
ado2Sub: "AI Marketing Automation Platform →", ado2Sub: "AI Marketing Automation Platform →",
@ -227,6 +243,14 @@ export const DICT: Record<Lang, Dict> = {
thisMatch: "This match", thisMatch: "This match",
showMore: (n) => `Show ${n} more`, showMore: (n) => `Show ${n} more`,
showLess: "Show less", 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", adLabel: "AD",
ado2Tagline: "AI Marketing Automation Platform", ado2Tagline: "AI Marketing Automation Platform",
ado2Sub: "Visit ado2.o2osolution.ai →", ado2Sub: "Visit ado2.o2osolution.ai →",

56
lib/leaderboard.ts Normal file
View File

@ -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<LeaderEntry[]> {
const list = tab === "ai" ? DEMO_AI : DEMO_VOTERS;
return [...list].sort((a, b) => b.points - a.points).slice(0, 10);
}