o2o-triple-pick/frontend/src/components/ScheduleBoard.tsx
jwkim 8da8b7e7bd 일정 카드 AI 픽을 팀 아래 모델 아이콘으로 표시
기존 "AI 픽 삼성 3" 텍스트 줄 대신 GPT/Claude/Gemini 아이콘을
각자 픽한 팀 쪽 아래에 배치 (무승부 픽은 가운데). 참여수는 우측 유지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:07:31 +09:00

686 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 : ["-"];
}