diff --git a/backend/app/domain.py b/backend/app/domain.py index 3b8dacc..eb538d4 100644 --- a/backend/app/domain.py +++ b/backend/app/domain.py @@ -11,12 +11,14 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from .config import settings -from .models import AIPrediction, CrowdStats, Match +from .models import AIPrediction, CrowdStats, Match, UserPrediction from .schemas import ( AIPredictionOut, CrowdStatsOut, MatchOut, MatchResult, + MyPredictionOut, + MyResultOut, 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( m: Match, lang: str = "ko", diff --git a/backend/app/routers/predictions.py b/backend/app/routers/predictions.py index 0230345..cfed9ab 100644 --- a/backend/app/routers/predictions.py +++ b/backend/app/routers/predictions.py @@ -7,10 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload 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 ..scoring import outcome_of -from ..schemas import SubmitPredictionIn, SubmitPredictionOut +from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut 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) async def submit_prediction( body: SubmitPredictionIn, db: AsyncSession = Depends(get_db) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e51b2d9..07bee6c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -90,6 +90,26 @@ class SubmitPredictionOut(BaseModel): 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): rank: int diff --git a/frontend/public/audio/Mexico vs Korea 2026.mp3 b/frontend/public/audio/Mexico vs Korea 2026.mp3 new file mode 100644 index 0000000..aec1cd0 Binary files /dev/null and b/frontend/public/audio/Mexico vs Korea 2026.mp3 differ diff --git a/frontend/public/thumbnail/Mexico vs Korea 2026.webp b/frontend/public/thumbnail/Mexico vs Korea 2026.webp new file mode 100644 index 0000000..6a4ed66 Binary files /dev/null and b/frontend/public/thumbnail/Mexico vs Korea 2026.webp differ diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index d276f2b..6afc368 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -7,14 +7,28 @@ import type { ModelName, Match, AIPrediction, + MyPrediction, Team, } 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 TeamFlag from "./TeamFlag"; 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 = { GPT: "/icons/gpt.png", Claude: "/icons/claude.jpg", @@ -51,6 +65,8 @@ export default function Arena({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [showRules, setShowRules] = useState(false); + const [myPreds, setMyPreds] = useState([]); + const [votesExpanded, setVotesExpanded] = useState(false); // 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스) useEffect(() => { @@ -59,6 +75,25 @@ export default function Arena({ ); }, [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 라벨. const finished = !!match.result; const disabled = finished || !match.votingOpen; @@ -98,6 +133,8 @@ export default function Arena({ setCrowd(res.crowd); // 서버 권위 분포로 갱신 rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억 setRecentEmails(getRecentEmails()); + // 방금 제출 포함해 내 지난 예측 갱신(이번 경기 강조/upsert 반영) + fetchMyPredictions(email.trim()).then(setMyPreds).catch(() => {}); setStep("done"); } catch (e) { const msg = @@ -304,7 +341,13 @@ export default function Arena({ list="tp-email-suggestions" autoComplete="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} 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({ )} + {/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */} + {myPreds.length > 0 && ( +
+
+

{t.myVotesTitle}

+ + {t.myVotesSub} + +
+
    + {(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 ( +
  • +
    +
    + {pA} {p.scoreA}-{p.scoreB} {pB} +
    +
    + {pickTxt} + {isThis && ( + · {t.thisMatch} + )} +
    +
    + {p.result ? ( + + {p.result.hitOutcome ? t.hit : t.miss} + + ) : ( + + {fmtSubmitted(p.submittedAt)} + + )} +
  • + ); + })} +
+ {myPreds.length > 1 && ( + + )} +
+ )} + {/* ===== 골드 상금 (003) ===== */}
diff --git a/frontend/src/components/Comments.tsx b/frontend/src/components/Comments.tsx index fb1b40f..b30c2ec 100644 --- a/frontend/src/components/Comments.tsx +++ b/frontend/src/components/Comments.tsx @@ -180,7 +180,7 @@ export default function Comments({ ) : (
    {visible.map((c, i) => ( diff --git a/frontend/src/globals.css b/frontend/src/globals.css index 72b874a..d0d8265 100644 --- a/frontend/src/globals.css +++ b/frontend/src/globals.css @@ -21,7 +21,7 @@ --gold-btn-2: #15a99b; /* 틸그린 */ --gpt: #15a99b; /* 틸그린 (teal green) */ - --claude: #e8814a; + /* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */ --gemini: #5b8cff; --share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */ @@ -152,6 +152,25 @@ body { 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 { from { width: 0; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dad01d8..2b1caa6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,7 @@ import type { Leaderboard, Match, ModelName, + MyPrediction, } from "./types"; import type { Lang } from "./i18n"; @@ -73,6 +74,13 @@ export function getLeaderboard(): Promise { return http(`/leaderboard`); } +// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열. +export function fetchMyPredictions(email: string): Promise { + const e = email.trim(); + if (!e) return Promise.resolve([]); + return http(`/predictions/mine?email=${encodeURIComponent(e)}`); +} + // ── 댓글(경기별 한마디) — 완전 익명 ── export function listComments( matchId: string, diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index bef3d72..462717c 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -100,6 +100,12 @@ type Dict = { footerDisc3: string; hook: (a: string, b: string) => string; moreMatches: string; + // 내 지난 예측 (이메일 기준) + myVotesTitle: string; + myVotesSub: string; + thisMatch: string; + myVotesShowMore: (n: number) => string; + myVotesShowLess: string; commentsTitle: string; commentsCount: (n: number) => string; commentPh: string; @@ -193,6 +199,11 @@ export const DICT: Record = { footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.", hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`, moreMatches: "다른 경기 투표하기", + myVotesTitle: "내 지난 예측", + myVotesSub: "이메일 기준 · 최신순", + thisMatch: "이번 경기", + myVotesShowMore: (n) => `더보기 +${n}`, + myVotesShowLess: "접기", commentsTitle: "한마디", commentsCount: (n) => `총 ${n}개`, commentPh: "응원 한마디 남기기...", @@ -283,6 +294,11 @@ export const DICT: Record = { 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}`, 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", commentsCount: (n) => `${n} total`, commentPh: "Leave a comment...", diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts index adbe48e..cafa612 100644 --- a/frontend/src/lib/playlist.ts +++ b/frontend/src/lib/playlist.ts @@ -13,12 +13,14 @@ export type Track = { // VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다. export const PATH_PLAYLISTS: Record = { "/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: "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": [ { 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" }, ], }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e50edbe..10d76e7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -62,6 +62,24 @@ export interface UserPick { 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 { rank: number; emailMasked: string;