내 지난 얘측 보기 기능 추가, 한국 멕시코 경기 응원가 1곡 추가
parent
0e5a63a28b
commit
b4fc96f299
|
|
@ -11,12 +11,14 @@ from __future__ import annotations
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .models import AIPrediction, CrowdStats, Match
|
from .models import AIPrediction, CrowdStats, Match, UserPrediction
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
AIPredictionOut,
|
AIPredictionOut,
|
||||||
CrowdStatsOut,
|
CrowdStatsOut,
|
||||||
MatchOut,
|
MatchOut,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
|
MyPredictionOut,
|
||||||
|
MyResultOut,
|
||||||
Team,
|
Team,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -116,6 +118,29 @@ def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def my_prediction_out(p: UserPrediction, m: Match) -> MyPredictionOut:
|
||||||
|
"""유저 픽 1건을 경기 정보와 합쳐 직렬화 (내 지난 예측 목록용)."""
|
||||||
|
result = None
|
||||||
|
if m.result_outcome is not None:
|
||||||
|
result = MyResultOut(
|
||||||
|
scoreA=m.result_score_a or 0,
|
||||||
|
scoreB=m.result_score_b or 0,
|
||||||
|
outcome=m.result_outcome, # type: ignore[arg-type]
|
||||||
|
hitOutcome=p.outcome == m.result_outcome,
|
||||||
|
)
|
||||||
|
return MyPredictionOut(
|
||||||
|
matchId=p.match_id,
|
||||||
|
teamA=_team_a(m),
|
||||||
|
teamB=_team_b(m),
|
||||||
|
kickoffKst=kst_iso(m.kickoff_at),
|
||||||
|
outcome=p.outcome, # type: ignore[arg-type]
|
||||||
|
scoreA=p.score_a,
|
||||||
|
scoreB=p.score_b,
|
||||||
|
submittedAt=kst_iso(p.updated_at or p.created_at),
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def match_out(
|
def match_out(
|
||||||
m: Match,
|
m: Match,
|
||||||
lang: str = "ko",
|
lang: str = "ko",
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..domain import compute_phase, crowd_out, is_open_for_voting, is_votable
|
from ..domain import (
|
||||||
|
compute_phase,
|
||||||
|
crowd_out,
|
||||||
|
is_open_for_voting,
|
||||||
|
is_votable,
|
||||||
|
my_prediction_out,
|
||||||
|
)
|
||||||
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
|
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
|
||||||
from ..scoring import outcome_of
|
from ..scoring import outcome_of
|
||||||
from ..schemas import SubmitPredictionIn, SubmitPredictionOut
|
from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/predictions", tags=["predictions"])
|
router = APIRouter(prefix="/api/predictions", tags=["predictions"])
|
||||||
|
|
||||||
|
|
@ -36,6 +42,25 @@ async def _adjust_crowd(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mine", response_model=list[MyPredictionOut])
|
||||||
|
async def my_predictions(
|
||||||
|
email: str, db: AsyncSession = Depends(get_db)
|
||||||
|
) -> list[MyPredictionOut]:
|
||||||
|
"""이메일 기준 내 지난 예측 목록 (최신 제출순). 로그인 없음 — 이메일이 신원."""
|
||||||
|
e = email.strip().lower()
|
||||||
|
if not e:
|
||||||
|
return []
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPrediction, Match)
|
||||||
|
.join(Match, Match.match_id == UserPrediction.match_id)
|
||||||
|
.where(func.lower(UserPrediction.email) == e)
|
||||||
|
.order_by(UserPrediction.updated_at.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [my_prediction_out(up, m) for up, m in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SubmitPredictionOut)
|
@router.post("", response_model=SubmitPredictionOut)
|
||||||
async def submit_prediction(
|
async def submit_prediction(
|
||||||
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)
|
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,26 @@ class SubmitPredictionOut(BaseModel):
|
||||||
crowd: CrowdStatsOut
|
crowd: CrowdStatsOut
|
||||||
|
|
||||||
|
|
||||||
|
# ── 내 지난 예측 (이메일 기준, 로그인 없음) ──────────────────
|
||||||
|
class MyResultOut(BaseModel):
|
||||||
|
scoreA: int
|
||||||
|
scoreB: int
|
||||||
|
outcome: Outcome
|
||||||
|
hitOutcome: bool # 내 outcome 이 실제 결과와 일치했는지
|
||||||
|
|
||||||
|
|
||||||
|
class MyPredictionOut(BaseModel):
|
||||||
|
matchId: str
|
||||||
|
teamA: Team
|
||||||
|
teamB: Team
|
||||||
|
kickoffKst: str
|
||||||
|
outcome: Outcome
|
||||||
|
scoreA: int
|
||||||
|
scoreB: int
|
||||||
|
submittedAt: str # ISO — 최신순 정렬 기준(updated_at)
|
||||||
|
result: MyResultOut | None = None # 경기 종료 시에만 채워짐
|
||||||
|
|
||||||
|
|
||||||
# ── 리더보드 ────────────────────────────────────────────────
|
# ── 리더보드 ────────────────────────────────────────────────
|
||||||
class StandingOut(BaseModel):
|
class StandingOut(BaseModel):
|
||||||
rank: int
|
rank: int
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
|
|
@ -7,14 +7,28 @@ import type {
|
||||||
ModelName,
|
ModelName,
|
||||||
Match,
|
Match,
|
||||||
AIPrediction,
|
AIPrediction,
|
||||||
|
MyPrediction,
|
||||||
Team,
|
Team,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { ApiError, getDeviceId, getRecentEmails, rememberEmail, submitPrediction } from "@/lib/api";
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchMyPredictions,
|
||||||
|
getDeviceId,
|
||||||
|
getRecentEmails,
|
||||||
|
rememberEmail,
|
||||||
|
submitPrediction,
|
||||||
|
} from "@/lib/api";
|
||||||
import Countdown from "./Countdown";
|
import Countdown from "./Countdown";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
||||||
type Step = "form" | "done";
|
type Step = "form" | "done";
|
||||||
|
|
||||||
|
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
|
||||||
|
function fmtSubmitted(iso: string): string {
|
||||||
|
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${+m[1]}.${+m[2]}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
const MODEL_ICON: Record<ModelName, string> = {
|
const MODEL_ICON: Record<ModelName, string> = {
|
||||||
GPT: "/icons/gpt.png",
|
GPT: "/icons/gpt.png",
|
||||||
Claude: "/icons/claude.jpg",
|
Claude: "/icons/claude.jpg",
|
||||||
|
|
@ -51,6 +65,8 @@ export default function Arena({
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showRules, setShowRules] = useState(false);
|
const [showRules, setShowRules] = useState(false);
|
||||||
|
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
|
||||||
|
const [votesExpanded, setVotesExpanded] = useState(false);
|
||||||
|
|
||||||
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -59,6 +75,25 @@ export default function Arena({
|
||||||
);
|
);
|
||||||
}, [scoreA, scoreB]);
|
}, [scoreA, scoreB]);
|
||||||
|
|
||||||
|
// 재방문 시: 가장 최근 사용 이메일로 내 지난 예측 자동 로드 (로그인 대체).
|
||||||
|
// 이메일 칸은 비워둠 — develop UX(포커스 시 datalist 자동완성)를 유지.
|
||||||
|
useEffect(() => {
|
||||||
|
const last = getRecentEmails()[0];
|
||||||
|
if (last) fetchMyPredictions(last).then(setMyPreds).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 목록에서 강조)
|
||||||
|
const myThisMatch = useMemo(
|
||||||
|
() => myPreds.find((p) => p.matchId === match.matchId),
|
||||||
|
[myPreds, match.matchId],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (myThisMatch) {
|
||||||
|
setScoreA(myThisMatch.scoreA);
|
||||||
|
setScoreB(myThisMatch.scoreB);
|
||||||
|
}
|
||||||
|
}, [myThisMatch]);
|
||||||
|
|
||||||
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
|
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
|
||||||
const finished = !!match.result;
|
const finished = !!match.result;
|
||||||
const disabled = finished || !match.votingOpen;
|
const disabled = finished || !match.votingOpen;
|
||||||
|
|
@ -98,6 +133,8 @@ export default function Arena({
|
||||||
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
||||||
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
|
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
|
||||||
setRecentEmails(getRecentEmails());
|
setRecentEmails(getRecentEmails());
|
||||||
|
// 방금 제출 포함해 내 지난 예측 갱신(이번 경기 강조/upsert 반영)
|
||||||
|
fetchMyPredictions(email.trim()).then(setMyPreds).catch(() => {});
|
||||||
setStep("done");
|
setStep("done");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|
@ -304,7 +341,13 @@ export default function Arena({
|
||||||
list="tp-email-suggestions"
|
list="tp-email-suggestions"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setEmail(v);
|
||||||
|
// 유효 이메일이면 그 즉시 내 지난 예측 조회(다른 기기/세션 흔적까지 표시)
|
||||||
|
if (EMAIL_RE.test(v.trim()))
|
||||||
|
fetchMyPredictions(v.trim()).then(setMyPreds).catch(() => {});
|
||||||
|
}}
|
||||||
placeholder={t.emailPh}
|
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)]"
|
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)]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -366,6 +409,79 @@ export default function Arena({
|
||||||
</div>
|
</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 pA = teamShort(p.teamA, lang);
|
||||||
|
const pB = teamShort(p.teamB, lang);
|
||||||
|
const pickTxt =
|
||||||
|
p.outcome === "TEAM_A_WIN"
|
||||||
|
? `${pA} ${t.win}`
|
||||||
|
: p.outcome === "TEAM_B_WIN"
|
||||||
|
? `${pB} ${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">
|
||||||
|
{pA} {p.scoreA}-{p.scoreB} {pB}
|
||||||
|
</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.myVotesShowLess
|
||||||
|
: t.myVotesShowMore(myPreds.length - 1)}
|
||||||
|
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ===== 골드 상금 (003) ===== */}
|
{/* ===== 골드 상금 (003) ===== */}
|
||||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export default function Comments({
|
||||||
) : (
|
) : (
|
||||||
<ul
|
<ul
|
||||||
className={`mt-4 divide-y divide-[var(--line-d)] ${
|
className={`mt-4 divide-y divide-[var(--line-d)] ${
|
||||||
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
|
expanded ? "max-h-[360px] overflow-y-auto pr-1 scroll-dark" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{visible.map((c, i) => (
|
{visible.map((c, i) => (
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
--gold-btn-2: #15a99b; /* 틸그린 */
|
--gold-btn-2: #15a99b; /* 틸그린 */
|
||||||
|
|
||||||
--gpt: #15a99b; /* 틸그린 (teal green) */
|
--gpt: #15a99b; /* 틸그린 (teal green) */
|
||||||
--claude: #e8814a;
|
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
|
||||||
--gemini: #5b8cff;
|
--gemini: #5b8cff;
|
||||||
|
|
||||||
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
||||||
|
|
@ -152,6 +152,25 @@ body {
|
||||||
box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3);
|
box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 다크 스크롤바 — 흰색 기본 스크롤바 제거 */
|
||||||
|
.scroll-dark {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--line-d) transparent;
|
||||||
|
}
|
||||||
|
.scroll-dark::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.scroll-dark::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.scroll-dark::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line-d);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.scroll-dark::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #3a414e;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes barfill {
|
@keyframes barfill {
|
||||||
from {
|
from {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
Leaderboard,
|
Leaderboard,
|
||||||
Match,
|
Match,
|
||||||
ModelName,
|
ModelName,
|
||||||
|
MyPrediction,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { Lang } from "./i18n";
|
import type { Lang } from "./i18n";
|
||||||
|
|
||||||
|
|
@ -73,6 +74,13 @@ export function getLeaderboard(): Promise<Leaderboard> {
|
||||||
return http<Leaderboard>(`/leaderboard`);
|
return http<Leaderboard>(`/leaderboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열.
|
||||||
|
export function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
|
||||||
|
const e = email.trim();
|
||||||
|
if (!e) return Promise.resolve([]);
|
||||||
|
return http<MyPrediction[]>(`/predictions/mine?email=${encodeURIComponent(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 댓글(경기별 한마디) — 완전 익명 ──
|
// ── 댓글(경기별 한마디) — 완전 익명 ──
|
||||||
export function listComments(
|
export function listComments(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,12 @@ type Dict = {
|
||||||
footerDisc3: string;
|
footerDisc3: string;
|
||||||
hook: (a: string, b: string) => string;
|
hook: (a: string, b: string) => string;
|
||||||
moreMatches: string;
|
moreMatches: string;
|
||||||
|
// 내 지난 예측 (이메일 기준)
|
||||||
|
myVotesTitle: string;
|
||||||
|
myVotesSub: string;
|
||||||
|
thisMatch: string;
|
||||||
|
myVotesShowMore: (n: number) => string;
|
||||||
|
myVotesShowLess: string;
|
||||||
commentsTitle: string;
|
commentsTitle: string;
|
||||||
commentsCount: (n: number) => string;
|
commentsCount: (n: number) => string;
|
||||||
commentPh: string;
|
commentPh: string;
|
||||||
|
|
@ -193,6 +199,11 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
||||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
||||||
moreMatches: "다른 경기 투표하기",
|
moreMatches: "다른 경기 투표하기",
|
||||||
|
myVotesTitle: "내 지난 예측",
|
||||||
|
myVotesSub: "이메일 기준 · 최신순",
|
||||||
|
thisMatch: "이번 경기",
|
||||||
|
myVotesShowMore: (n) => `더보기 +${n}`,
|
||||||
|
myVotesShowLess: "접기",
|
||||||
commentsTitle: "한마디",
|
commentsTitle: "한마디",
|
||||||
commentsCount: (n) => `총 ${n}개`,
|
commentsCount: (n) => `총 ${n}개`,
|
||||||
commentPh: "응원 한마디 남기기...",
|
commentPh: "응원 한마디 남기기...",
|
||||||
|
|
@ -283,6 +294,11 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
|
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
|
||||||
hook: (a, b) => `AI is split on ${a} vs ${b}`,
|
hook: (a, b) => `AI is split on ${a} vs ${b}`,
|
||||||
moreMatches: "Vote on another match",
|
moreMatches: "Vote on another match",
|
||||||
|
myVotesTitle: "Your past predictions",
|
||||||
|
myVotesSub: "By email · newest first",
|
||||||
|
thisMatch: "This match",
|
||||||
|
myVotesShowMore: (n) => `Show ${n} more`,
|
||||||
|
myVotesShowLess: "Show less",
|
||||||
commentsTitle: "Comments",
|
commentsTitle: "Comments",
|
||||||
commentsCount: (n) => `${n} total`,
|
commentsCount: (n) => `${n} total`,
|
||||||
commentPh: "Leave a comment...",
|
commentPh: "Leave a comment...",
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ export type Track = {
|
||||||
// VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다.
|
// VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다.
|
||||||
export const PATH_PLAYLISTS: Record<string, Track[]> = {
|
export const PATH_PLAYLISTS: Record<string, Track[]> = {
|
||||||
"/match/A_MEX_KOR_20260619": [
|
"/match/A_MEX_KOR_20260619": [
|
||||||
{ title: "Red Wave 2026", artist: "aio2o", src: "/audio/Red Wave 2026.mp3", cover: "/thumbnail/Red Wave 2026.webp" },
|
{ title: "El Tri vs 붉은 악마", artist: "aio2o", src: "/audio/Mexico vs Korea 2026.mp3", cover: "/thumbnail/Mexico vs Korea 2026.webp" },
|
||||||
|
{ title: "Red Wave: 멕시코를 넘어", artist: "aio2o", src: "/audio/Red Wave 2026.mp3", cover: "/thumbnail/Red Wave 2026.webp" },
|
||||||
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
||||||
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
|
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
|
||||||
],
|
],
|
||||||
"/match/A_RSA_KOR_20260625": [
|
"/match/A_RSA_KOR_20260625": [
|
||||||
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
||||||
|
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,24 @@ export interface UserPick {
|
||||||
scoreB: number;
|
scoreB: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 내 지난 예측 (이메일 기준, 로그인 없음) — GET /api/predictions/mine
|
||||||
|
export interface MyPrediction {
|
||||||
|
matchId: string;
|
||||||
|
teamA: Team;
|
||||||
|
teamB: Team;
|
||||||
|
kickoffKst: string;
|
||||||
|
outcome: Outcome;
|
||||||
|
scoreA: number;
|
||||||
|
scoreB: number;
|
||||||
|
submittedAt: string; // ISO — 최신순 정렬 기준
|
||||||
|
result?: {
|
||||||
|
scoreA: number;
|
||||||
|
scoreB: number;
|
||||||
|
outcome: Outcome;
|
||||||
|
hitOutcome: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Standing {
|
export interface Standing {
|
||||||
rank: number;
|
rank: number;
|
||||||
emailMasked: string;
|
emailMasked: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue