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
parent
b69b1dbe97
commit
965bd374c3
|
|
@ -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 (
|
||||
<main className="shell">
|
||||
<Hero back />
|
||||
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
|
||||
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
|
||||
<p className="mt-3 text-[15px] font-bold">랭킹은 곧 공개됩니다</p>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
|
||||
매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께
|
||||
최종 100만원을 드립니다.
|
||||
</p>
|
||||
</section>
|
||||
<p className="mt-6 text-center text-[13px] leading-relaxed text-[var(--ink-muted)]">
|
||||
{t.goldDesc}
|
||||
</p>
|
||||
{/* 전체 페이지: 펼친 상태(폴딩 토글·전체보기 링크 숨김) */}
|
||||
<Leaderboard lang={lang} defaultOpen showAllLink={false} />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== 누적 랭킹 TOP 10 (골드 카드 직후 · 폴딩) ===== */}
|
||||
<Leaderboard lang={lang} />
|
||||
|
||||
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
|
||||
<section className="mt-6">
|
||||
<div className="mb-2.5 text-[20px] font-extrabold">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
24
lib/i18n.ts
24
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<Lang, Dict> = {
|
||||
|
|
@ -162,6 +170,14 @@ export const DICT: Record<Lang, Dict> = {
|
|||
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<Lang, Dict> = {
|
|||
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 →",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue