기존 "AI 픽 삼성 3" 텍스트 줄 대신 GPT/Claude/Gemini 아이콘을 각자 픽한 팀 쪽 아래에 배치 (무승부 픽은 가운데). 참여수는 우측 유지. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
686 lines
25 KiB
TypeScript
686 lines
25 KiB
TypeScript
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { timeOnly, dateKey } from "@/lib/format";
|
||
import { getLive, type LiveData } from "@/lib/api";
|
||
import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n";
|
||
import type { League, Match, MatchPhase, ModelName, Outcome } from "@/lib/types";
|
||
import TeamFlag from "./TeamFlag";
|
||
import StandingsTable from "./StandingsTable";
|
||
|
||
// 칩용 짧은 날짜: "6.12 (금)" / "Jun 12 (Fri)"
|
||
function shortDate(iso: string, lang: Lang): string {
|
||
const m = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||
if (!m) return iso;
|
||
const [, y, mo, d] = m;
|
||
const dow = dayName(lang, new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay());
|
||
return lang === "en" ? `${monthEn(+mo)} ${+d} (${dow})` : `${+mo}.${+d} (${dow})`;
|
||
}
|
||
|
||
const PHASE_CLS: Record<MatchPhase, string> = {
|
||
open: "border-[#94FBE0] text-[#94FBE0]",
|
||
scheduled: "border-white/20 text-white/60",
|
||
locked: "border-white/20 text-white/60",
|
||
live: "border-[#FF5B5B] text-[#FF5B5B]",
|
||
finished: "border-white/25 text-white/70",
|
||
cancelled: "border-white/10 text-white/40",
|
||
};
|
||
|
||
// 토너먼트 탭 라운드 칩 정렬(대진 순서)
|
||
const KO_ROUND_ORDER = ["32강", "16강", "8강", "4강", "3·4위전", "결승"];
|
||
const isGroupLetter = (s: string) => /^[A-Z]$/.test(s);
|
||
|
||
type Tab = "date" | "group" | "tournament" | "team" | "rank";
|
||
|
||
export default function ScheduleBoard({
|
||
matches,
|
||
lang = "ko",
|
||
league = "wc",
|
||
}: {
|
||
matches: Match[];
|
||
lang?: Lang;
|
||
league?: League;
|
||
}) {
|
||
const t = dict(lang);
|
||
// 야구(KBO/MLB)는 조/토너먼트 대신 날짜별/팀별 탭 제공
|
||
const isBaseball = league !== "wc";
|
||
|
||
// 날짜 목록 (정렬)
|
||
const dates = useMemo(
|
||
() => Array.from(new Set(matches.map((m) => dateKey(m.kickoffKst)))).sort(),
|
||
[matches],
|
||
);
|
||
// 조별리그 탭 칩: 조 문자(A~L)
|
||
const groups = useMemo(() => {
|
||
const set = new Set(matches.map((m) => m.group).filter(Boolean));
|
||
return [...set].filter(isGroupLetter).sort();
|
||
}, [matches]);
|
||
// 토너먼트 탭 칩: 등장한 라운드(32강~결승), 대진 순서
|
||
const rounds = useMemo(() => {
|
||
const set = new Set(matches.map((m) => m.roundLabel).filter(Boolean));
|
||
return KO_ROUND_ORDER.filter((r) => set.has(r));
|
||
}, [matches]);
|
||
// 현재 진행 라운드: 아직 종료되지 않은 경기가 있는 가장 이른 라운드.
|
||
// (8강 진행 중이면 8강, 8강이 다 끝나 4강 대진이 뜨면 4강.) 전부 종료면 가장 진행된 라운드.
|
||
const currentRound = useMemo(() => {
|
||
for (const r of rounds) {
|
||
if (matches.some((m) => m.roundLabel === r && !m.result)) return r;
|
||
}
|
||
return rounds[rounds.length - 1] ?? "";
|
||
}, [matches, rounds]);
|
||
|
||
// 시작 탭: 토너먼트가 시작됐으면 '토너먼트', 아니면 '날짜별'.
|
||
// 단, 직전 세션 선택이 있으면 우선(상세 진입 후 뒤로가기 복원).
|
||
const [tab, setTabRaw] = useState<Tab>(() => {
|
||
const saved = sessionStorage.getItem("tp.sched.tab");
|
||
if (
|
||
saved === "date" || saved === "group" || saved === "tournament" ||
|
||
saved === "team" || saved === "rank"
|
||
)
|
||
return saved;
|
||
if (isBaseball) return "date";
|
||
return rounds.length > 0 ? "tournament" : "date";
|
||
});
|
||
// 리그에 없는 탭이 저장돼 있으면 날짜별로 강등 (야구↔축구 전환 시)
|
||
const effTab: Tab = isBaseball
|
||
? (tab === "team" || tab === "rank" ? tab : "date")
|
||
: (tab === "team" || tab === "rank" ? "date" : tab);
|
||
const setTab = setTabRaw;
|
||
|
||
// 팀별 탭: 등장 팀 목록 (짧은 이름 가나다/알파벳 순)
|
||
const teams = useMemo(() => {
|
||
if (!isBaseball) return [];
|
||
const byCode = new Map<string, Match["teamA"]>();
|
||
for (const m of matches) {
|
||
byCode.set(m.teamA.code, m.teamA);
|
||
byCode.set(m.teamB.code, m.teamB);
|
||
}
|
||
return [...byCode.values()].sort((a, b) =>
|
||
teamShort(a, lang).localeCompare(teamShort(b, lang), lang === "en" ? "en" : "ko"),
|
||
);
|
||
}, [matches, isBaseball, lang]);
|
||
|
||
// 기본 선택: 오늘(경기 없으면 다음 경기일, 시즌 종료면 마지막 날짜) / A조 / 현재 진행 라운드.
|
||
// 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X).
|
||
// 사이트 전체가 KST 기준이라 "오늘"도 클라이언트 시간대와 무관하게 KST 로 계산.
|
||
const todayKey = useMemo(
|
||
() => new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10),
|
||
[],
|
||
);
|
||
const defaultDate = (ds: string[], today: string) =>
|
||
(ds.includes(today) ? today : ds.find((d) => d > today) ?? ds[ds.length - 1]) ?? "";
|
||
const [selDate, setSelDate] = useState(() => {
|
||
const saved = sessionStorage.getItem("tp.sched.date");
|
||
if (saved && dates.includes(saved)) return saved;
|
||
return defaultDate(dates, todayKey);
|
||
});
|
||
const [selGroup, setSelGroup] = useState(() => {
|
||
const saved = sessionStorage.getItem("tp.sched.group");
|
||
if (saved && isGroupLetter(saved)) return saved;
|
||
return (groups.includes("A") ? "A" : groups[0]) ?? "";
|
||
});
|
||
const [selRound, setSelRound] = useState(() => {
|
||
const saved = sessionStorage.getItem("tp.sched.round");
|
||
if (saved && rounds.includes(saved)) return saved;
|
||
return currentRound;
|
||
});
|
||
const [selTeam, setSelTeam] = useState(
|
||
() => sessionStorage.getItem("tp.sched.team") ?? "",
|
||
);
|
||
|
||
// 선택 변경 시 보관(다음 진입/뒤로가기에서 복원)
|
||
useEffect(() => {
|
||
if (selDate) sessionStorage.setItem("tp.sched.date", selDate);
|
||
}, [selDate]);
|
||
useEffect(() => {
|
||
if (selGroup) sessionStorage.setItem("tp.sched.group", selGroup);
|
||
}, [selGroup]);
|
||
useEffect(() => {
|
||
if (selRound) sessionStorage.setItem("tp.sched.round", selRound);
|
||
}, [selRound]);
|
||
useEffect(() => {
|
||
sessionStorage.setItem("tp.sched.tab", tab);
|
||
}, [tab]);
|
||
useEffect(() => {
|
||
if (selTeam) sessionStorage.setItem("tp.sched.team", selTeam);
|
||
}, [selTeam]);
|
||
|
||
useEffect(() => {
|
||
if (dates.length && !dates.includes(selDate)) {
|
||
// 목록이 비동기 로드될 때도 세션 복원이 동작하게 saved 를 우선 확인
|
||
const saved = sessionStorage.getItem("tp.sched.date");
|
||
setSelDate(
|
||
saved && dates.includes(saved) ? saved : defaultDate(dates, todayKey),
|
||
);
|
||
}
|
||
}, [dates, selDate, todayKey]);
|
||
// 리그 전환 시 목록에 없는 팀이면 첫 팀으로
|
||
useEffect(() => {
|
||
if (teams.length && !teams.some((tm) => tm.code === selTeam)) {
|
||
setSelTeam(teams[0].code);
|
||
}
|
||
}, [teams, selTeam]);
|
||
|
||
const shown = useMemo(() => {
|
||
let list: Match[];
|
||
if (effTab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate);
|
||
else if (effTab === "team")
|
||
list = matches.filter((m) => m.teamA.code === selTeam || m.teamB.code === selTeam);
|
||
else if (effTab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup);
|
||
else list = matches.filter((m) => m.roundLabel === selRound); // 토너먼트
|
||
return [...list].sort(
|
||
(a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(),
|
||
);
|
||
}, [matches, effTab, selDate, selGroup, selRound, selTeam]);
|
||
|
||
const tabLabel = (k: Tab) =>
|
||
k === "date"
|
||
? lang === "en"
|
||
? "By date"
|
||
: "날짜별"
|
||
: k === "team"
|
||
? lang === "en"
|
||
? "By team"
|
||
: "팀별"
|
||
: k === "rank"
|
||
? lang === "en"
|
||
? "Standings"
|
||
: "순위"
|
||
: k === "group"
|
||
? lang === "en"
|
||
? "Groups"
|
||
: "조별리그"
|
||
: lang === "en"
|
||
? "Tournament"
|
||
: "토너먼트";
|
||
|
||
const leagueTabs: Tab[] = isBaseball
|
||
? ["date", "team", "rank"]
|
||
: ["date", "group", "tournament"];
|
||
|
||
return (
|
||
<section className="mt-6">
|
||
<div className="mb-3 flex items-baseline gap-2.5">
|
||
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
|
||
<span className="whitespace-nowrap text-[12px] text-white/65">{t.schedGuide}</span>
|
||
</div>
|
||
|
||
{/* 탭: 축구 = 날짜별/조별리그/토너먼트 · 야구 = 날짜별/팀별 */}
|
||
<div className="mb-3 flex gap-5 border-b border-[var(--line-d)]">
|
||
{leagueTabs.map((k) => (
|
||
<button
|
||
key={k}
|
||
onClick={() => setTab(k)}
|
||
className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${
|
||
effTab === k
|
||
? "border-[var(--green)] text-white"
|
||
: "border-transparent text-white/60"
|
||
}`}
|
||
>
|
||
{tabLabel(k)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 순위 탭: 칩/카드 대신 순위표 */}
|
||
{effTab === "rank" && <StandingsTable league={league} lang={lang} />}
|
||
|
||
{/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
|
||
{effTab !== "rank" && (
|
||
<ChipScroller
|
||
resetKey={`${league}:${effTab}`}
|
||
centerKey={
|
||
effTab === "date"
|
||
? selDate
|
||
: effTab === "team"
|
||
? selTeam
|
||
: effTab === "group"
|
||
? selGroup
|
||
: selRound
|
||
}
|
||
>
|
||
{effTab === "date"
|
||
? dates.map((d) => (
|
||
<Chip key={d} active={d === selDate} onClick={() => setSelDate(d)}>
|
||
{shortDate(d, lang)}
|
||
</Chip>
|
||
))
|
||
: effTab === "team"
|
||
? teams.map((tm) => (
|
||
<Chip key={tm.code} active={tm.code === selTeam} onClick={() => setSelTeam(tm.code)}>
|
||
<span className="flex items-center gap-1.5">
|
||
<TeamFlag team={tm} className="h-4 w-4 shrink-0" />
|
||
{teamShort(tm, lang)}
|
||
</span>
|
||
</Chip>
|
||
))
|
||
: effTab === "group"
|
||
? groups.map((g) => (
|
||
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
|
||
{lang === "en" ? `Group ${g}` : `${g}조`}
|
||
</Chip>
|
||
))
|
||
: rounds.map((r) => (
|
||
<Chip key={r} active={r === selRound} onClick={() => setSelRound(r)}>
|
||
{tRound(r, lang)}
|
||
</Chip>
|
||
))}
|
||
</ChipScroller>
|
||
)}
|
||
|
||
{/* 경기 카드 */}
|
||
{effTab !== "rank" && (
|
||
<div className="flex flex-col gap-2.5">
|
||
{shown.length === 0 && (
|
||
<p className="py-6 text-center text-[13px] text-white/60">
|
||
{effTab === "tournament"
|
||
? lang === "en"
|
||
? "Bracket is set after the group stage."
|
||
: "토너먼트 대진은 조별리그 종료 후 확정됩니다"
|
||
: lang === "en"
|
||
? "No matches"
|
||
: "경기가 없습니다"}
|
||
</p>
|
||
)}
|
||
{shown.map((m) => (
|
||
<MatchCard key={m.matchId} match={m} lang={lang} showDate={effTab === "team"} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 단일 줄 가로 스크롤 + 양 끝 화살표. 끝에 닿으면 해당 방향 화살표를 숨긴다.
|
||
// resetKey 가 바뀌면(탭 전환 등) 맨 앞으로 되감고 재측정한다.
|
||
function ChipScroller({
|
||
children,
|
||
resetKey,
|
||
centerKey,
|
||
}: {
|
||
children: React.ReactNode;
|
||
resetKey?: string | number;
|
||
centerKey?: string; // 현재 선택값 — 바뀔 때마다 선택 칩을 가운데로 (비동기 로드·칩 클릭 포함)
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const [atStart, setAtStart] = useState(true);
|
||
const [atEnd, setAtEnd] = useState(true);
|
||
|
||
const measure = () => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const max = el.scrollWidth - el.clientWidth;
|
||
setAtStart(el.scrollLeft <= 1);
|
||
setAtEnd(el.scrollLeft >= max - 1);
|
||
};
|
||
|
||
// 탭/목록/선택 변경 시: 선택된 칩을 가운데로 + 재측정 (레이아웃 확정 후)
|
||
// 선택 칩이 없으면 맨 앞으로 되감는다.
|
||
useLayoutEffect(() => {
|
||
const el = ref.current;
|
||
if (el) {
|
||
const active = el.querySelector<HTMLElement>('[data-active="true"]');
|
||
el.scrollLeft = active
|
||
? active.offsetLeft - (el.clientWidth - active.clientWidth) / 2
|
||
: 0;
|
||
}
|
||
measure();
|
||
}, [resetKey, centerKey]);
|
||
|
||
useEffect(() => {
|
||
measure();
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
el.addEventListener("scroll", measure, { passive: true });
|
||
window.addEventListener("resize", measure);
|
||
return () => {
|
||
el.removeEventListener("scroll", measure);
|
||
window.removeEventListener("resize", measure);
|
||
};
|
||
}, []);
|
||
|
||
const nudge = (dir: 1 | -1) =>
|
||
ref.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
|
||
|
||
return (
|
||
<div className="relative mb-4">
|
||
<div
|
||
ref={ref}
|
||
className="flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||
>
|
||
{children}
|
||
</div>
|
||
|
||
{!atStart && (
|
||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pr-8 bg-gradient-to-r from-[var(--bg)] via-[var(--bg)] to-transparent">
|
||
<button
|
||
type="button"
|
||
aria-label="이전"
|
||
onClick={() => nudge(-1)}
|
||
className="pointer-events-auto grid h-7 w-7 place-items-center rounded-full border border-[var(--line-d)] bg-[var(--bg2)] text-[15px] leading-none text-white/80 shadow transition hover:text-white active:scale-95"
|
||
>
|
||
‹
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!atEnd && (
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-end pl-8 bg-gradient-to-l from-[var(--bg)] via-[var(--bg)] to-transparent">
|
||
<button
|
||
type="button"
|
||
aria-label="다음"
|
||
onClick={() => nudge(1)}
|
||
className="pointer-events-auto grid h-7 w-7 place-items-center rounded-full border border-[var(--line-d)] bg-[var(--bg2)] text-[15px] leading-none text-white/80 shadow transition hover:text-white active:scale-95"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Chip({
|
||
active,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
data-active={active}
|
||
className={`shrink-0 whitespace-nowrap rounded-full border px-3.5 py-1.5 text-[13px] font-bold transition ${
|
||
active
|
||
? "border-[#94FBE0] text-[#94FBE0]"
|
||
: "border-white/20 text-white/70"
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// MLS 최근 5경기 폼 — "WWLDW" 를 W/D/L 색으로 표시
|
||
export function FormBadges({ form }: { form?: string | null }) {
|
||
if (!form) return <span className="text-white/50">?</span>;
|
||
return (
|
||
<span className="inline-flex gap-px font-mono font-extrabold tracking-tight">
|
||
{form.split("").map((c, i) => (
|
||
<span
|
||
key={i}
|
||
className={
|
||
c === "W"
|
||
? "text-[#94FBE0]"
|
||
: c === "L"
|
||
? "text-[#FF5B5B]/80"
|
||
: "text-white/60"
|
||
}
|
||
>
|
||
{c}
|
||
</span>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// 팀명 옆 순위 배지 (야구·MLS — 순위 캐시 없으면 렌더 안 함)
|
||
function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) {
|
||
if (!rank) return null;
|
||
return (
|
||
<span className="shrink-0 rounded border border-white/25 px-1 py-px text-[11px] font-bold text-white/60">
|
||
{lang === "en" ? `#${rank}` : `${rank}위`}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// AI 픽 아이콘 (일정 카드용 — Arena 와 동일 공식 로고)
|
||
const MODEL_ICON: Record<ModelName, string> = {
|
||
GPT: "/icons/gpt.png",
|
||
Claude: "/icons/claude.jpg",
|
||
Gemini: "/icons/gemini.jpeg",
|
||
};
|
||
|
||
function AiPickIcons({ models }: { models: ModelName[] }) {
|
||
return (
|
||
<>
|
||
{models.map((m) => (
|
||
<img
|
||
key={m}
|
||
src={MODEL_ICON[m]}
|
||
alt={m}
|
||
title={m}
|
||
className="h-[18px] w-[18px] shrink-0 rounded-full border border-white/25 object-cover"
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function MatchCard({
|
||
match,
|
||
lang,
|
||
showDate = false,
|
||
}: {
|
||
match: Match;
|
||
lang: Lang;
|
||
showDate?: boolean;
|
||
}) {
|
||
const t = dict(lang);
|
||
const phase = match.phase;
|
||
const finished = !!match.result;
|
||
const isBaseball = match.league === "kbo" || match.league === "mlb";
|
||
const isMls = match.league === "mls";
|
||
const isClub = isBaseball || isMls; // 클럽 리그 공통 UI (원정-홈 고정·로고·순위 뱃지)
|
||
|
||
// 진행 중: 회차(야구)/경기 분(MLS)·스코어 표시 (서버 15초 캐시, 30초 폴링)
|
||
const [live, setLive] = useState<LiveData | null>(null);
|
||
useEffect(() => {
|
||
if (phase !== "live" || !isClub) return;
|
||
let alive = true;
|
||
const load = () =>
|
||
getLive(match.matchId)
|
||
.then((d) => {
|
||
if (alive && d.available) setLive(d);
|
||
})
|
||
.catch(() => {});
|
||
load();
|
||
const id = setInterval(load, 30_000);
|
||
return () => {
|
||
alive = false;
|
||
clearInterval(id);
|
||
};
|
||
}, [match.matchId, phase, isClub]);
|
||
// 야구 "6회초" / "Top 6" · MLS 경기 분 "45'"
|
||
const liveBadge = isMls
|
||
? live?.clock || null
|
||
: live?.inn != null
|
||
? lang === "en"
|
||
? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}`
|
||
: `${live.inn}회${live.half === "B" ? "말" : "초"}`
|
||
: null;
|
||
// 킥오프 시각은 지났지만 소스가 아직 시작 전(state=pre)이라는 경기 — 지연 등
|
||
const notStarted = isMls && phase === "live" && live?.state === "pre";
|
||
// 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀을 왼쪽에(축구 관례). 데이터는 원본 A/B 유지, 화면 좌우만 교체.
|
||
const flip =
|
||
isMls || (!isClub && match.teamB.code === "KOR" && match.teamA.code !== "KOR");
|
||
const left = flip ? match.teamB : match.teamA;
|
||
const right = flip ? match.teamA : match.teamB;
|
||
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA;
|
||
const rightScore = flip ? match.result?.scoreA : match.result?.scoreB;
|
||
const leftSt = flip ? match.extras?.standings?.b : match.extras?.standings?.a;
|
||
const rightSt = flip ? match.extras?.standings?.a : match.extras?.standings?.b;
|
||
const flagCls = isClub ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0";
|
||
|
||
// AI 픽: 픽한 팀 아래에 모델 아이콘 배치 (무승부 픽은 가운데)
|
||
const picksFor = (o: Outcome) =>
|
||
match.predictions.filter((p) => p.outcome === o).map((p) => p.model);
|
||
const leftPicks = picksFor(flip ? "TEAM_B_WIN" : "TEAM_A_WIN");
|
||
const rightPicks = picksFor(flip ? "TEAM_A_WIN" : "TEAM_B_WIN");
|
||
const drawPicks = picksFor("DRAW");
|
||
const showAiRow =
|
||
match.votable && phase !== "cancelled" && match.predictions.length > 0;
|
||
|
||
// 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
|
||
const inner = (
|
||
<>
|
||
<div className="flex items-center justify-between text-[12px]">
|
||
<span className="font-mono font-bold text-white/85">
|
||
{showDate && (
|
||
<span className="text-white/65">{shortDate(dateKey(match.kickoffKst), lang)} </span>
|
||
)}
|
||
{timeOnly(match.kickoffKst)} <span className="text-white/55">KST</span>
|
||
{isClub && match.venue ? (
|
||
<span className="text-white/55"> · {match.venue}</span>
|
||
) : match.group && match.roundLabel ? (
|
||
<> · {tRound(match.roundLabel, lang)}</>
|
||
) : null}
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
{!isClub && (
|
||
<span className="text-[11px] font-bold text-white/60">
|
||
{match.group
|
||
? lang === "en"
|
||
? `Group ${match.group}`
|
||
: `${match.group}조`
|
||
: tRound(match.roundLabel, lang)}
|
||
</span>
|
||
)}
|
||
<span
|
||
className={`rounded-md border px-2 py-0.5 font-semibold ${
|
||
notStarted ? PHASE_CLS.scheduled : PHASE_CLS[phase]
|
||
}`}
|
||
>
|
||
{notStarted
|
||
? lang === "en" ? "Not started" : "시작 전"
|
||
: phase === "live" && liveBadge
|
||
? liveBadge
|
||
: t.phase[phase]}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<TeamFlag team={left} className={flagCls} />
|
||
<span className="truncate text-[15px] font-extrabold">{teamShort(left, lang)}</span>
|
||
{isClub && <RankBadge rank={leftSt?.rank} lang={lang} />}
|
||
</div>
|
||
{match.result ? (
|
||
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
|
||
{leftScore}
|
||
<span className="px-1.5 text-white/40">:</span>
|
||
{rightScore}
|
||
</span>
|
||
) : phase === "live" && live?.score ? (
|
||
// 진행 중 실시간 스코어 — flip 시(MLS) 홈 먼저
|
||
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
|
||
{(flip ? live.score.home : live.score.away) ?? 0}
|
||
<span className="px-1.5 text-[#FF5B5B]">:</span>
|
||
{(flip ? live.score.away : live.score.home) ?? 0}
|
||
</span>
|
||
) : (
|
||
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
|
||
)}
|
||
<div className="flex items-center justify-end gap-2">
|
||
{isClub && <RankBadge rank={rightSt?.rank} lang={lang} />}
|
||
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(right, lang)}</span>
|
||
<TeamFlag team={right} className={flagCls} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI 픽: 각 모델 아이콘을 픽한 팀 아래에 표시 (무승부 픽은 가운데) */}
|
||
{showAiRow && (
|
||
<div
|
||
className="mt-1.5 grid grid-cols-[1fr_auto_1fr] items-center gap-2"
|
||
aria-label={`${t.aiPicks}: ${aiSplit(match, lang).join(", ")}`}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
<AiPickIcons models={leftPicks} />
|
||
</div>
|
||
<div className="flex items-center justify-center gap-1">
|
||
<AiPickIcons models={drawPicks} />
|
||
</div>
|
||
<div className="flex items-center justify-end gap-1">
|
||
<AiPickIcons models={rightPicks} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 야구: 선발 매치업 (프리뷰 캐시 있을 때만) */}
|
||
{isBaseball && !match.result && (match.extras?.starterA || match.extras?.starterB) && (
|
||
<div className="mt-2 text-center text-[12px] font-semibold text-white/65">
|
||
<span className="text-white/50">{lang === "en" ? "SP" : "선발"}</span>{" "}
|
||
{match.extras?.starterA?.name ?? "?"}
|
||
<span className="px-1 text-white/45">vs</span>
|
||
{match.extras?.starterB?.name ?? "?"}
|
||
</div>
|
||
)}
|
||
|
||
{/* MLS: 최근 5경기 폼 (프리뷰 캐시 있을 때만) — 홈(왼쪽) 먼저 */}
|
||
{isMls && !match.result && (match.extras?.formA || match.extras?.formB) && (
|
||
<div className="mt-2 text-center text-[12px] font-semibold text-white/65">
|
||
<span className="text-white/50">{lang === "en" ? "Last 5" : "최근 5경기"}</span>{" "}
|
||
<FormBadges form={match.extras?.formB} />
|
||
<span className="px-1 text-white/45">vs</span>
|
||
<FormBadges form={match.extras?.formA} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 투표 가능 조만: 참여수 + 이동 화살표 (AI 픽은 위의 아이콘 행이 담당, 취소 경기는 숨김) */}
|
||
{match.votable && phase !== "cancelled" && (
|
||
<div className="mt-3 flex items-center justify-end text-[12px] font-bold text-[#F4F3FE]">
|
||
<span className="flex items-center gap-2">
|
||
<span>{t.joined((match.crowd?.total ?? 0).toLocaleString())}</span>
|
||
<span className="text-[#94FBE0]">→</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const base = "block rounded-2xl border-2 bg-[#171b21] p-4";
|
||
// 취소 경기: 클릭 불가·저채도 표시 전용
|
||
if (phase === "cancelled") {
|
||
return <div className={`${base} border-[var(--line-d)] opacity-60`}>{inner}</div>;
|
||
}
|
||
if (match.votable && !finished) {
|
||
return (
|
||
<Link
|
||
to={`/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`}
|
||
className={`${base} border-[#94FBE0]/45 transition active:scale-[0.99] hover:border-[#94FBE0]`}
|
||
>
|
||
{inner}
|
||
</Link>
|
||
);
|
||
}
|
||
// 종료된 투표 경기는 결과 보기로, 비투표 경기는 표시 전용(클릭 시 상세=결과)
|
||
if (match.votable) {
|
||
return (
|
||
<Link to={`/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`} className={`${base} border-white/12`}>
|
||
{inner}
|
||
</Link>
|
||
);
|
||
}
|
||
return <div className={`${base} border-[var(--line-d)]`}>{inner}</div>;
|
||
}
|
||
|
||
function aiSplit(match: Match, lang: Lang): string[] {
|
||
const t = dict(lang);
|
||
const tally = { a: 0, d: 0, b: 0 };
|
||
for (const p of match.predictions) {
|
||
if (p.outcome === "TEAM_A_WIN") tally.a++;
|
||
else if (p.outcome === "DRAW") tally.d++;
|
||
else tally.b++;
|
||
}
|
||
const entA = tally.a ? `${teamShort(match.teamA, lang)} ${tally.a}` : null;
|
||
const entD = tally.d ? `${t.draw} ${tally.d}` : null;
|
||
const entB = tally.b ? `${teamShort(match.teamB, lang)} ${tally.b}` : null;
|
||
// MLS 는 홈 팀 먼저 (카드 좌우 표시와 동일 순서)
|
||
const out = (match.league === "mls" ? [entB, entD, entA] : [entA, entD, entB]).filter(
|
||
(x): x is string => !!x,
|
||
);
|
||
return out.length ? out : ["-"];
|
||
}
|