diff --git a/backend/Dockerfile b/backend/Dockerfile
index 76c5b37..716e187 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -15,6 +15,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
+COPY data ./data
EXPOSE 8000
diff --git a/backend/app/main.py b/backend/app/main.py
index f0460cd..7bcbbc7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .database import init_db
from .routers import admin, leaderboard, matches, predictions
+from .scoring import load_scoring_data
from .seed import seed_if_empty
logging.basicConfig(level=logging.INFO)
@@ -22,6 +23,7 @@ log = logging.getLogger("triplepick")
@asynccontextmanager
async def lifespan(app: FastAPI): # noqa: ANN201
+ load_scoring_data() # data/scoring.json → 배점·배제 대상
await init_db()
await seed_if_empty()
log.info("API ready")
diff --git a/backend/app/models.py b/backend/app/models.py
index ed60d63..dc4df40 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -5,6 +5,7 @@
- ai_predictions GPT/Claude/Gemini 예측 (경기 × 모델)
- crowd_stats 군중 투표 분포 (경기당 1행, 원자적 증분)
- user_predictions 유저 픽 (이메일 식별, 채점/알림 플래그 포함)
+- user_points 유저별 누적 포인트 (채점 시 갱신, 이메일당 1행)
"""
from __future__ import annotations
@@ -152,3 +153,28 @@ class UserPrediction(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+
+
+class UserPoints(Base):
+ """유저별 누적 포인트 — 채점(grade_prediction) 결과를 이메일 단위로 집계.
+
+ 등급별 횟수 컬럼은 scoring.json 의 key(score_*) 와 1:1 대응.
+ exact_count 는 리더보드 동점 보정 1순위(정확 스코어 횟수)에 사용.
+ """
+
+ __tablename__ = "user_points"
+
+ email: Mapped[str] = mapped_column(String, primary_key=True) # 소문자 정규화
+ total_points: Mapped[int] = mapped_column(Integer, default=0)
+ exact_count: Mapped[int] = mapped_column(Integer, default=0)
+ close_count: Mapped[int] = mapped_column(Integer, default=0)
+ outcome_count: Mapped[int] = mapped_column(Integer, default=0)
+ partial_count: Mapped[int] = mapped_column(Integer, default=0)
+ miss_count: Mapped[int] = mapped_column(Integer, default=0)
+ matches_played: Mapped[int] = mapped_column(Integer, default=0)
+ first_scored_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py
index 1052a7d..8735bde 100644
--- a/backend/app/routers/leaderboard.py
+++ b/backend/app/routers/leaderboard.py
@@ -1,19 +1,18 @@
"""리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙).
+user_points 테이블(채점 시 누적 갱신) 기준 랭킹.
1차 정렬: 누적 포인트. 동점: 정확스코어 횟수 → 적중률 → 참여수 → 최초도달.
주최측/임직원 배제(P1). 이메일은 마스킹하여 노출.
"""
from __future__ import annotations
-from collections import defaultdict
-
from fastapi import APIRouter, Depends, Query
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
-from ..models import UserPrediction
-from ..scoring import SCORE, is_excluded
+from ..models import Match, UserPoints
+from ..scoring import is_excluded
from ..schemas import LeaderboardOut, StandingOut
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
@@ -30,51 +29,36 @@ async def leaderboard(
limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db)
) -> LeaderboardOut:
rows = (
- await db.execute(
- select(UserPrediction).where(
- UserPrediction.points.is_not(None),
- UserPrediction.email.is_not(None),
- )
- )
+ await db.execute(select(UserPoints))
).scalars().all()
- agg: dict[str, dict] = defaultdict(
- lambda: {"points": 0, "exact": 0, "played": 0, "first": None}
- )
- matches: set[str] = set()
- for r in rows:
- email = (r.email or "").strip().lower()
- if not email or is_excluded(email):
- continue
- matches.add(r.match_id)
- a = agg[email]
- a["points"] += r.points or 0
- a["played"] += 1
- if (r.points or 0) >= SCORE["EXACT"]:
- a["exact"] += 1
- ts = r.scored_at or r.created_at
- if a["first"] is None or (ts and ts < a["first"]):
- a["first"] = ts
-
ranked = sorted(
- agg.items(),
- key=lambda kv: (
- -kv[1]["points"],
- -kv[1]["exact"],
- -(kv[1]["points"] / kv[1]["played"] if kv[1]["played"] else 0),
- -kv[1]["played"],
- kv[1]["first"].timestamp() if kv[1]["first"] else 0,
+ (r for r in rows if not is_excluded(r.email)),
+ key=lambda r: (
+ -r.total_points,
+ -r.exact_count,
+ -(r.total_points / r.matches_played if r.matches_played else 0),
+ -r.matches_played,
+ r.first_scored_at.timestamp() if r.first_scored_at else 0,
),
)
+ scored_matches = (
+ await db.execute(
+ select(func.count())
+ .select_from(Match)
+ .where(Match.result_outcome.is_not(None))
+ )
+ ).scalar() or 0
+
standings = [
StandingOut(
rank=i + 1,
- emailMasked=_mask(email),
- totalPoints=v["points"],
- exactCount=v["exact"],
- matchesPlayed=v["played"],
+ emailMasked=_mask(r.email),
+ totalPoints=r.total_points,
+ exactCount=r.exact_count,
+ matchesPlayed=r.matches_played,
)
- for i, (email, v) in enumerate(ranked[:limit])
+ for i, r in enumerate(ranked[:limit])
]
- return LeaderboardOut(standings=standings, scoredMatches=len(matches))
+ return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
diff --git a/backend/app/scoring.py b/backend/app/scoring.py
index 82d3c06..b3b526e 100644
--- a/backend/app/scoring.py
+++ b/backend/app/scoring.py
@@ -1,11 +1,18 @@
"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현.
배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0.
-단조 증가(정확>근접>승패>부분>빗나감). 배점 수치는 SCORE 상수만 교체하면 됨.
+단조 증가(정확>근접>승패>부분>빗나감).
+배점·배제 대상은 backend/data/scoring.json 에서 기동 시 로드(없으면 아래 기본값).
"""
from __future__ import annotations
-# 팀 확정 후 이 상수만 교체 (docs/SCORING.md §2)
+import json
+import logging
+from pathlib import Path
+
+log = logging.getLogger("triplepick.scoring")
+
+# 기본 배점 — backend/data/scoring.json 로드 시 덮어씀 (docs/SCORING.md §2)
SCORE = {"EXACT": 5, "CLOSE": 3, "OUTCOME": 2, "PARTIAL": 1, "MISS": 0}
@@ -40,3 +47,87 @@ STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"]
def is_excluded(email: str) -> bool:
e = email.strip().lower()
return e in STAFF_EMAILS or any(e.endswith("@" + d) for d in STAFF_DOMAINS)
+
+
+# ── 외부 설정 로드 (backend/data/scoring.json, key-value) ──────
+DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "scoring.json"
+
+# 등급 → scoring.json 의 key
+_JSON_KEY = {
+ "EXACT": "score_exact",
+ "CLOSE": "score_close",
+ "OUTCOME": "score_outcome",
+ "PARTIAL": "score_partial",
+ "MISS": "score_miss",
+}
+
+
+def grade_prediction(
+ pick_a: int, pick_b: int, result_a: int, result_b: int,
+ path: Path | None = None,
+) -> tuple[str, int]:
+ """유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value).
+
+ score_prediction 과 같은 판정 기준. 예: 승패+득실차 일치 → ("score_close", 3).
+ scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체.
+ """
+ p = path or DATA_FILE
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
+ data = {}
+
+ if pick_a == result_a and pick_b == result_b:
+ grade = "EXACT"
+ elif outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b):
+ grade = "CLOSE" if (pick_a - pick_b) == (result_a - result_b) else "OUTCOME"
+ elif pick_a == result_a or pick_b == result_b:
+ grade = "PARTIAL"
+ else:
+ grade = "MISS"
+
+ key = _JSON_KEY[grade]
+ value = data[key] if isinstance(data.get(key), int) else SCORE[grade]
+ return key, value
+
+_SCORE_KEYS = {
+ "score_exact": "EXACT",
+ "score_close": "CLOSE",
+ "score_outcome": "OUTCOME",
+ "score_partial": "PARTIAL",
+ "score_miss": "MISS",
+}
+
+
+def load_scoring_data(path: Path | None = None) -> dict:
+ """data/scoring.json 을 읽어 배점(SCORE)·배제 대상을 갱신. 기동 시 1회 호출.
+
+ SCORE/STAFF_EMAILS/STAFF_DOMAINS 는 타 모듈이 import 한 객체라 재할당 대신
+ in-place 갱신. 파일이 없거나 깨져도 기본값으로 동작(부팅 실패 방지).
+ """
+ p = path or DATA_FILE
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ log.warning("scoring data 없음 (%s) — 기본 배점 사용", p)
+ return {}
+ except (OSError, json.JSONDecodeError) as e:
+ log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
+ return {}
+
+ for key, score_key in _SCORE_KEYS.items():
+ if isinstance(data.get(key), int):
+ SCORE[score_key] = data[key]
+ if isinstance(data.get("excluded_emails"), list):
+ STAFF_EMAILS.clear()
+ STAFF_EMAILS.update(e.strip().lower() for e in data["excluded_emails"])
+ if isinstance(data.get("excluded_domains"), list):
+ STAFF_DOMAINS[:] = [
+ d.strip().lower().lstrip("@") for d in data["excluded_domains"]
+ ]
+ log.info(
+ "scoring data 로드 (%s): 배점 %s · 배제 이메일 %d · 도메인 %s",
+ p, SCORE, len(STAFF_EMAILS), STAFF_DOMAINS,
+ )
+ return data
diff --git a/backend/app/services/grading.py b/backend/app/services/grading.py
index eb95117..835cba6 100644
--- a/backend/app/services/grading.py
+++ b/backend/app/services/grading.py
@@ -1,6 +1,7 @@
-"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점.
+"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점 + 유저별 누적.
-관리자 setResult 와 워커가 공용으로 사용. scoring.score_prediction 적용.
+관리자 setResult 와 워커가 공용으로 사용. scoring.grade_prediction 적용
+(scoring.json 배점) 후 services.points 로 user_points 누적 갱신.
"""
from __future__ import annotations
@@ -11,13 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import now_utc
from ..models import Match, UserPrediction
-from ..scoring import outcome_of, score_prediction
+from ..scoring import grade_prediction, outcome_of
+from .points import accumulate_user_points
log = logging.getLogger("triplepick.grading")
async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: int) -> int:
- """결과 기록 + 해당 경기 픽 전수 채점. 채점된 픽 수 반환."""
+ """결과 기록 + 해당 경기 픽 전수 채점 + 유저별 포인트 누적. 채점된 픽 수 반환."""
match.result_score_a = score_a
match.result_score_b = score_b
match.result_outcome = outcome_of(score_a, score_b)
@@ -31,9 +33,13 @@ async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: in
).scalars().all()
for p in picks:
- p.points = score_prediction(p.score_a, p.score_b, score_a, score_b)
+ key, value = grade_prediction(p.score_a, p.score_b, score_a, score_b)
+ p.points = value
p.scored_at = now_utc()
+ # 채점된 픽의 유저(이메일)별 누적 포인트 갱신 — 같은 트랜잭션
+ await accumulate_user_points(db, {p.email for p in picks})
+
await db.commit()
log.info("graded %d picks for %s (%d-%d)", len(picks), match.match_id, score_a, score_b)
return len(picks)
diff --git a/backend/app/services/points.py b/backend/app/services/points.py
new file mode 100644
index 0000000..538e763
--- /dev/null
+++ b/backend/app/services/points.py
@@ -0,0 +1,99 @@
+"""유저별 포인트 누적 — grade_prediction 의 (key, value) 를 user_points 에 반영.
+
+채점(apply_result)에서 호출. 결과 정정(재채점)에도 안전하도록 영향받은
+이메일의 채점 가능한 픽 전체를 재집계해 upsert 한다(idempotent — 몇 번을
+다시 돌려도 같은 결과, 증분 방식의 이중 누적 위험 없음).
+"""
+from __future__ import annotations
+
+import logging
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from ..domain import now_utc
+from ..models import Match, UserPoints, UserPrediction
+from ..scoring import grade_prediction
+
+log = logging.getLogger("triplepick.points")
+
+# scoring.json 의 key → user_points 등급별 횟수 컬럼
+_KEY_TO_COL = {
+ "score_exact": "exact_count",
+ "score_close": "close_count",
+ "score_outcome": "outcome_count",
+ "score_partial": "partial_count",
+ "score_miss": "miss_count",
+}
+
+
+def _empty() -> dict:
+ return {
+ "total_points": 0,
+ "exact_count": 0,
+ "close_count": 0,
+ "outcome_count": 0,
+ "partial_count": 0,
+ "miss_count": 0,
+ "matches_played": 0,
+ "first_scored_at": None,
+ }
+
+
+async def accumulate_user_points(
+ db: AsyncSession, emails: set[str | None]
+) -> int:
+ """이메일별 누적 포인트 재집계 → user_points upsert. 갱신 행 수 반환.
+
+ 커밋은 호출자(apply_result) 책임 — 채점과 누적이 한 트랜잭션으로 묶인다.
+ """
+ targets = {e.strip().lower() for e in emails if e and e.strip()}
+ if not targets:
+ return 0
+
+ rows = (
+ await db.execute(
+ select(UserPrediction, Match)
+ .join(Match, UserPrediction.match_id == Match.match_id)
+ .where(
+ func.lower(UserPrediction.email).in_(targets),
+ Match.result_score_a.is_not(None),
+ Match.result_score_b.is_not(None),
+ )
+ )
+ ).all()
+
+ agg: dict[str, dict] = {e: _empty() for e in targets}
+ for pick, match in rows:
+ key, value = grade_prediction(
+ pick.score_a, pick.score_b, match.result_score_a, match.result_score_b
+ )
+ t = agg[pick.email.strip().lower()]
+ t["total_points"] += value
+ t[_KEY_TO_COL[key]] += 1
+ t["matches_played"] += 1
+ ts = pick.scored_at or pick.created_at
+ if ts and (t["first_scored_at"] is None or ts < t["first_scored_at"]):
+ t["first_scored_at"] = ts
+
+ existing = {
+ up.email: up
+ for up in (
+ await db.execute(select(UserPoints).where(UserPoints.email.in_(targets)))
+ ).scalars()
+ }
+ for email, t in agg.items():
+ row = existing.get(email)
+ if row is None:
+ row = UserPoints(email=email)
+ db.add(row)
+ for col, val in t.items():
+ setattr(row, col, val)
+ row.updated_at = now_utc()
+
+ log.info(
+ "user_points: %d명 누적 갱신 (%s)",
+ len(agg),
+ ", ".join(f"{e}={t['total_points']}p" for e, t in agg.items()),
+ )
+ return len(agg)
diff --git a/backend/app/worker.py b/backend/app/worker.py
index 1231bef..37fcac8 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -29,6 +29,7 @@ from .config import settings
from .database import SessionLocal, init_db
from .domain import ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction
+from .scoring import load_scoring_data
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
from .services.email import EmailUnavailable, build_result_email, send_email
from .services.schedule_fetch import fetch_schedule
@@ -217,6 +218,7 @@ def build_scheduler() -> AsyncIOScheduler:
async def main() -> None:
+ load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용)
await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당.
# 기동 시 1회: 일정 동기화 → AI 예측 생성(키 있을 때 GPT·Gemini 즉시 채움)
await sync_schedule_job()
diff --git a/backend/data/scoring.json b/backend/data/scoring.json
new file mode 100644
index 0000000..feff08f
--- /dev/null
+++ b/backend/data/scoring.json
@@ -0,0 +1,7 @@
+{
+ "score_exact": 5,
+ "score_close": 3,
+ "score_outcome": 2,
+ "score_partial": 1,
+ "score_miss": 0
+}
diff --git a/frontend/public/assets/flags/alg.svg b/frontend/public/assets/flags/alg.svg
new file mode 100644
index 0000000..2b23ca4
--- /dev/null
+++ b/frontend/public/assets/flags/alg.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/arg.svg b/frontend/public/assets/flags/arg.svg
new file mode 100644
index 0000000..db54af5
--- /dev/null
+++ b/frontend/public/assets/flags/arg.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/aus.svg b/frontend/public/assets/flags/aus.svg
new file mode 100644
index 0000000..38a47de
--- /dev/null
+++ b/frontend/public/assets/flags/aus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/aut.svg b/frontend/public/assets/flags/aut.svg
new file mode 100644
index 0000000..1229a26
--- /dev/null
+++ b/frontend/public/assets/flags/aut.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/bel.svg b/frontend/public/assets/flags/bel.svg
new file mode 100644
index 0000000..107b639
--- /dev/null
+++ b/frontend/public/assets/flags/bel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/bih.svg b/frontend/public/assets/flags/bih.svg
new file mode 100644
index 0000000..177c78d
--- /dev/null
+++ b/frontend/public/assets/flags/bih.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/bra.svg b/frontend/public/assets/flags/bra.svg
new file mode 100644
index 0000000..203e64d
--- /dev/null
+++ b/frontend/public/assets/flags/bra.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/can.svg b/frontend/public/assets/flags/can.svg
new file mode 100644
index 0000000..154cb19
--- /dev/null
+++ b/frontend/public/assets/flags/can.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/civ.svg b/frontend/public/assets/flags/civ.svg
new file mode 100644
index 0000000..4448319
--- /dev/null
+++ b/frontend/public/assets/flags/civ.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/cod.svg b/frontend/public/assets/flags/cod.svg
new file mode 100644
index 0000000..5892af0
--- /dev/null
+++ b/frontend/public/assets/flags/cod.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/col.svg b/frontend/public/assets/flags/col.svg
new file mode 100644
index 0000000..2f3901d
--- /dev/null
+++ b/frontend/public/assets/flags/col.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/cpv.svg b/frontend/public/assets/flags/cpv.svg
new file mode 100644
index 0000000..0fd3001
--- /dev/null
+++ b/frontend/public/assets/flags/cpv.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/cro.svg b/frontend/public/assets/flags/cro.svg
new file mode 100644
index 0000000..ad24ab9
--- /dev/null
+++ b/frontend/public/assets/flags/cro.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/cuw.svg b/frontend/public/assets/flags/cuw.svg
new file mode 100644
index 0000000..e8953e6
--- /dev/null
+++ b/frontend/public/assets/flags/cuw.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/cze.svg b/frontend/public/assets/flags/cze.svg
new file mode 100644
index 0000000..3915399
--- /dev/null
+++ b/frontend/public/assets/flags/cze.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/ecu.svg b/frontend/public/assets/flags/ecu.svg
new file mode 100644
index 0000000..61979d7
--- /dev/null
+++ b/frontend/public/assets/flags/ecu.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/egy.svg b/frontend/public/assets/flags/egy.svg
new file mode 100644
index 0000000..95fdd19
--- /dev/null
+++ b/frontend/public/assets/flags/egy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/eng.svg b/frontend/public/assets/flags/eng.svg
new file mode 100644
index 0000000..f474d10
--- /dev/null
+++ b/frontend/public/assets/flags/eng.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/esp.svg b/frontend/public/assets/flags/esp.svg
new file mode 100644
index 0000000..8e6e4c2
--- /dev/null
+++ b/frontend/public/assets/flags/esp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/fra.svg b/frontend/public/assets/flags/fra.svg
new file mode 100644
index 0000000..6eb0e18
--- /dev/null
+++ b/frontend/public/assets/flags/fra.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/ger.svg b/frontend/public/assets/flags/ger.svg
new file mode 100644
index 0000000..60e5efc
--- /dev/null
+++ b/frontend/public/assets/flags/ger.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/gha.svg b/frontend/public/assets/flags/gha.svg
new file mode 100644
index 0000000..fae8956
--- /dev/null
+++ b/frontend/public/assets/flags/gha.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/hai.svg b/frontend/public/assets/flags/hai.svg
new file mode 100644
index 0000000..b6ca967
--- /dev/null
+++ b/frontend/public/assets/flags/hai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/irn.svg b/frontend/public/assets/flags/irn.svg
new file mode 100644
index 0000000..272eff0
--- /dev/null
+++ b/frontend/public/assets/flags/irn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/irq.svg b/frontend/public/assets/flags/irq.svg
new file mode 100644
index 0000000..2b62c58
--- /dev/null
+++ b/frontend/public/assets/flags/irq.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/jor.svg b/frontend/public/assets/flags/jor.svg
new file mode 100644
index 0000000..5a4eccf
--- /dev/null
+++ b/frontend/public/assets/flags/jor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/jpn.svg b/frontend/public/assets/flags/jpn.svg
new file mode 100644
index 0000000..0f02949
--- /dev/null
+++ b/frontend/public/assets/flags/jpn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/kor.svg b/frontend/public/assets/flags/kor.svg
new file mode 100644
index 0000000..a44c6e1
--- /dev/null
+++ b/frontend/public/assets/flags/kor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/ksa.svg b/frontend/public/assets/flags/ksa.svg
new file mode 100644
index 0000000..5eee1b5
--- /dev/null
+++ b/frontend/public/assets/flags/ksa.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/mar.svg b/frontend/public/assets/flags/mar.svg
new file mode 100644
index 0000000..d6b54a3
--- /dev/null
+++ b/frontend/public/assets/flags/mar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/mex.svg b/frontend/public/assets/flags/mex.svg
new file mode 100644
index 0000000..b82436b
--- /dev/null
+++ b/frontend/public/assets/flags/mex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/ned.svg b/frontend/public/assets/flags/ned.svg
new file mode 100644
index 0000000..6598c0b
--- /dev/null
+++ b/frontend/public/assets/flags/ned.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/nor.svg b/frontend/public/assets/flags/nor.svg
new file mode 100644
index 0000000..ebb8fbd
--- /dev/null
+++ b/frontend/public/assets/flags/nor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/nzl.svg b/frontend/public/assets/flags/nzl.svg
new file mode 100644
index 0000000..4f6af3c
--- /dev/null
+++ b/frontend/public/assets/flags/nzl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/pan.svg b/frontend/public/assets/flags/pan.svg
new file mode 100644
index 0000000..c625603
--- /dev/null
+++ b/frontend/public/assets/flags/pan.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/par.svg b/frontend/public/assets/flags/par.svg
new file mode 100644
index 0000000..63f38a7
--- /dev/null
+++ b/frontend/public/assets/flags/par.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/por.svg b/frontend/public/assets/flags/por.svg
new file mode 100644
index 0000000..0c610be
--- /dev/null
+++ b/frontend/public/assets/flags/por.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/qat.svg b/frontend/public/assets/flags/qat.svg
new file mode 100644
index 0000000..65b2184
--- /dev/null
+++ b/frontend/public/assets/flags/qat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/rsa.svg b/frontend/public/assets/flags/rsa.svg
new file mode 100644
index 0000000..a00b44a
--- /dev/null
+++ b/frontend/public/assets/flags/rsa.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/sco.svg b/frontend/public/assets/flags/sco.svg
new file mode 100644
index 0000000..be27bb2
--- /dev/null
+++ b/frontend/public/assets/flags/sco.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/sen.svg b/frontend/public/assets/flags/sen.svg
new file mode 100644
index 0000000..3860b33
--- /dev/null
+++ b/frontend/public/assets/flags/sen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/sui.svg b/frontend/public/assets/flags/sui.svg
new file mode 100644
index 0000000..f7833a5
--- /dev/null
+++ b/frontend/public/assets/flags/sui.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/swe.svg b/frontend/public/assets/flags/swe.svg
new file mode 100644
index 0000000..b32a8f9
--- /dev/null
+++ b/frontend/public/assets/flags/swe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/tun.svg b/frontend/public/assets/flags/tun.svg
new file mode 100644
index 0000000..482b55a
--- /dev/null
+++ b/frontend/public/assets/flags/tun.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/tur.svg b/frontend/public/assets/flags/tur.svg
new file mode 100644
index 0000000..5b046e6
--- /dev/null
+++ b/frontend/public/assets/flags/tur.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/uru.svg b/frontend/public/assets/flags/uru.svg
new file mode 100644
index 0000000..9f5b3d6
--- /dev/null
+++ b/frontend/public/assets/flags/uru.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/usa.svg b/frontend/public/assets/flags/usa.svg
new file mode 100644
index 0000000..9735dfa
--- /dev/null
+++ b/frontend/public/assets/flags/usa.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/flags/uzb.svg b/frontend/public/assets/flags/uzb.svg
new file mode 100644
index 0000000..379ab12
--- /dev/null
+++ b/frontend/public/assets/flags/uzb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/TeamFlag.tsx b/frontend/src/components/TeamFlag.tsx
index f96f842..370df2f 100644
--- a/frontend/src/components/TeamFlag.tsx
+++ b/frontend/src/components/TeamFlag.tsx
@@ -1,7 +1,9 @@
+import { useState } from "react";
import type { Team } from "@/lib/types";
// 모든 경기에 일반화된 국기 렌더.
-// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
+// 월드컵 출전 48개국 SVG 자산(/assets/flags/{fifa코드}.svg, 예: kor.svg)을 사용하고,
+// 자산이 없는 코드는 이모지 폴백(컨테이너 높이에 비례, 안 잘림).
export default function TeamFlag({
team,
className = "",
@@ -9,30 +11,20 @@ export default function TeamFlag({
team: Team;
className?: string;
}) {
- if (team.code === "KOR") {
+ const [failed, setFailed] = useState(false);
+
+ if (!failed && team.code) {
return (
setFailed(true)}
+ className={`flag-img object-cover border border-[var(--line-d)] ${className}`}
/>
);
}
- if (team.code === "CZE") {
- return (
-
- );
- }
- // 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
+
+ // 폴백 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
return (