diff --git a/package-lock.json b/package-lock.json index 51c729c..9e5fdf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1559,6 +1560,7 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1576,6 +1578,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1642,6 +1645,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -2162,6 +2166,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2432,6 +2437,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3042,6 +3048,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3212,6 +3219,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4877,6 +4885,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4889,6 +4898,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5684,6 +5694,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5762,6 +5773,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5837,6 +5849,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6148,6 +6161,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/api/feeds/queries.ts b/src/api/feeds/queries.ts index 9bb6bdc..c7358cd 100644 --- a/src/api/feeds/queries.ts +++ b/src/api/feeds/queries.ts @@ -5,6 +5,8 @@ import type { FeedListResponse } from "./types"; export const useGetFeeds = (playerId: number, size: number = 5) => { return useInfiniteQuery({ queryKey: ["feeds", playerId], + retry: false, + refetchOnWindowFocus: false, queryFn: async ({ pageParam }) => { const searchParams: Record = { size: String(size) }; if (pageParam) { diff --git a/src/api/instance.ts b/src/api/instance.ts index 04cfff9..e406237 100644 --- a/src/api/instance.ts +++ b/src/api/instance.ts @@ -1,16 +1,70 @@ import ky from "ky"; +import { refreshAccessToken } from "./auth"; + +let refreshPromise: Promise | null = null; + +const getFreshAccessToken = async () => { + if (!refreshPromise) { + refreshPromise = refreshAccessToken().finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; +}; const api = ky.create({ prefixUrl: "/api", hooks: { beforeRequest: [ (request) => { - const token = localStorage.getItem("accessToken"); // TODO: 실제 토큰 관리 로직으로 교체 + const token = localStorage.getItem("accessToken"); if (token) { request.headers.set("Authorization", `Bearer ${token}`); } }, ], + afterResponse: [ + async (request, options, response) => { + if (response.status !== 401) { + return response; + } + + const isRefreshRequest = request.url.includes("/auth/refresh"); + const alreadyRetried = request.headers.get("x-refresh-retry") === "1"; + const hasRefreshToken = !!localStorage.getItem("refreshToken"); + + if (isRefreshRequest || alreadyRetried || !hasRefreshToken) { + return response; + } + + try { + const newAccessToken = await getFreshAccessToken(); + + if (!newAccessToken) { + return response; + } + + const headers = new Headers(request.headers); + headers.set("Authorization", `Bearer ${newAccessToken}`); + headers.set("x-refresh-retry", "1"); + + return ky(request, { + ...options, + headers, + }); + } catch { + localStorage.removeItem("accessToken"); + localStorage.removeItem("refreshToken"); + + if (window.location.pathname !== "/login") { + window.location.href = "/login?error=true"; + } + + return response; + } + }, + ], }, }); diff --git a/src/api/match/queries.ts b/src/api/match/queries.ts index 68e886c..089c32b 100644 --- a/src/api/match/queries.ts +++ b/src/api/match/queries.ts @@ -9,6 +9,8 @@ import type { MatchSummaryResponse } from "./types"; export const useGetMatchSummary = (playerId: number) => { return useQuery({ queryKey: ["match", playerId], + retry: false, + refetchOnWindowFocus: false, queryFn: async () => { // API 경로가 /api/match/{player_id} 형태이면서 // 쿼리 파라미터 ?player_id=...도 함께 요구하는 경우를 모두 고려한 작성법입니다. diff --git a/src/api/stats/queries.ts b/src/api/stats/queries.ts index d20a315..0e50751 100644 --- a/src/api/stats/queries.ts +++ b/src/api/stats/queries.ts @@ -9,6 +9,8 @@ import type { PlayerStatsResponse } from "./types"; export const useGetPlayerStats = (playerId: number) => { return useQuery({ queryKey: ["stats", playerId], + retry: false, + refetchOnWindowFocus: false, queryFn: async () => { // 경로 파라미터와 쿼리 파라미터를 모두 포함하는 예시 주소 대응 return api.get(`stats/${playerId}`).json(); diff --git a/src/api/stats/types.ts b/src/api/stats/types.ts index 031be20..fe4f6c0 100644 --- a/src/api/stats/types.ts +++ b/src/api/stats/types.ts @@ -1,4 +1,3 @@ -//TODO : 투수의 경우는 다를 거임 export interface PlayerStatsSummary { batting_avg_rank: number; war_rank: number; @@ -7,8 +6,7 @@ export interface PlayerStatsSummary { home_run_rank: number; } -//TODO : 투수의 경우는 다를 거임 -export interface PlayerStatsMetrics { +export interface BatterStatsMetrics { batting_avg: number; home_runs: number; hits: number; @@ -18,9 +16,21 @@ export interface PlayerStatsMetrics { on_base_percentage: number; ops: number; } + +export interface PitcherStatsMetrics { + era: number; + games: number; + win_loss: string; + save_hold: string; + innings_pitched: number; + strikeouts: number; + walks: number; + whip: number; +} + export interface PlayerStatsResponse { season: number; - player_type: string; + player_type: "BATTER" | "PITCHER"; summary: PlayerStatsSummary; - metrics: PlayerStatsMetrics; + metrics: BatterStatsMetrics | PitcherStatsMetrics; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f92edfa..dd764a8 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -9,26 +9,111 @@ import { useGetPlayerStats } from "../api/stats/queries"; import { useGetMatchSummary } from "../api/match/queries"; import { useGetFeeds } from "../api/feeds/queries"; import { useGetFavoritePlayers } from "../api/favorites/queries"; +import type { + BatterStatsMetrics, + PitcherStatsMetrics, +} from "../api/stats/types"; import React from "react"; import { useInView } from "react-intersection-observer"; +import playersByTeamData from "../data/playersByTeam.json"; +import doosanLogo from "../assets/images/teamLogos/두산 베어스.svg"; +import lotteLogo from "../assets/images/teamLogos/롯데 자이언츠.svg"; +import samsungLogo from "../assets/images/teamLogos/삼성 라이온즈.svg"; +import kiwoomLogo from "../assets/images/teamLogos/키움 히어로즈.svg"; +import hanwhaLogo from "../assets/images/teamLogos/한화 이글스.svg"; +import kiaLogo from "../assets/images/teamLogos/KIA 타이거즈.svg"; +import ktLogo from "../assets/images/teamLogos/KT 위즈.svg"; +import lgLogo from "../assets/images/teamLogos/LG 트윈스.svg"; +import ncLogo from "../assets/images/teamLogos/NC 다이노스.svg"; +import ssgLogo from "../assets/images/teamLogos/SSG 랜더스.svg"; + +interface Player { + id: string; + name: string; +} + +interface TeamPlayers { + team: string; + players: Player[]; +} + +interface PlayersByTeamData { + teams: TeamPlayers[]; +} + +const playersByTeam = playersByTeamData as PlayersByTeamData; +const TEAM_LOGO_BY_KEY: Record = { + doosan: doosanLogo, + "두산": doosanLogo, + "두산베어스": doosanLogo, + lotte: lotteLogo, + "롯데": lotteLogo, + "롯데자이언츠": lotteLogo, + samsung: samsungLogo, + "삼성": samsungLogo, + "삼성라이온즈": samsungLogo, + kiwoom: kiwoomLogo, + "키움": kiwoomLogo, + "키움히어로즈": kiwoomLogo, + hanwha: hanwhaLogo, + "한화": hanwhaLogo, + "한화이글스": hanwhaLogo, + kia: kiaLogo, + "kiatigers": kiaLogo, + "kia타이거즈": kiaLogo, + kt: ktLogo, + "ktwiz": ktLogo, + "kt위즈": ktLogo, + lg: lgLogo, + "lgtwins": lgLogo, + "lg트윈스": lgLogo, + nc: ncLogo, + "ncdinos": ncLogo, + "nc다이노스": ncLogo, + ssg: ssgLogo, + "ssglanders": ssgLogo, + "ssg랜더스": ssgLogo, +}; + +const normalizeTeamKey = (value?: string) => + value?.toLowerCase().replace(/\s+/g, "").trim() ?? ""; + +const getTeamLogo = (teamId?: string, teamName?: string) => + TEAM_LOGO_BY_KEY[normalizeTeamKey(teamId)] ?? + TEAM_LOGO_BY_KEY[normalizeTeamKey(teamName)] ?? + undefined; const HomePage = () => { const { data: favorites } = useGetFavoritePlayers(); const playerIds = useMemo(() => favorites?.playerIds ?? [], [favorites]); + const playerNameById = useMemo(() => { + return playersByTeam.teams.reduce>((acc, team) => { + team.players.forEach((player) => { + acc[Number(player.id)] = player.name; + }); + return acc; + }, {}); + }, []); const [selectedPlayerId, setSelectedPlayerId] = useState(null); - //TODO : 로직 살리기 - // const activePlayerId = selectedPlayerId ?? playerIds[0] ?? null; - const activePlayerId = 54400; + const activePlayerId = useMemo(() => { + if (selectedPlayerId !== null && playerIds.includes(selectedPlayerId)) { + return selectedPlayerId; + } - // useEffect(() => { - // if (playerIds.length > 0 && selectedPlayerId === null) { - // setSelectedPlayerId(playerIds[0]); - // } - // }, [playerIds, selectedPlayerId]); + return playerIds[0] ?? null; + }, [playerIds, selectedPlayerId]); const { data: stats } = useGetPlayerStats(activePlayerId ?? 0); const { data: matchSummary } = useGetMatchSummary(activePlayerId ?? 0); + const batterStats = + stats?.player_type === "BATTER" + ? (stats.metrics as BatterStatsMetrics) + : null; + const pitcherStats = + stats?.player_type === "PITCHER" + ? (stats.metrics as PitcherStatsMetrics) + : null; const { data: feedList, fetchNextPage, @@ -60,9 +145,9 @@ const HomePage = () => { {playerIds.map((playerId) => ( setSelectedPlayerId(playerId)} /> ))} @@ -75,90 +160,151 @@ const HomePage = () => { {/* Today's Match Section */}
Today's Match - - - - - {matchSummary?.match.home.team_name} - - {matchSummary?.match.home_score} - : - {matchSummary?.match.away_score} - - - {matchSummary?.match.away.team_name} - - - - - - {matchSummary?.highlight.text} - - {matchSummary?.highlight.batting_record} - - - - + {matchSummary ? ( + + + + + {matchSummary.match.home.team_name} + + {matchSummary.match.home_score} + : + {matchSummary.match.away_score} + + + {matchSummary.match.away.team_name} + + + + + + {matchSummary.highlight.text} + + {`${matchSummary.match.status} · ${matchSummary.match.inning}`} + + + + + ) : ( + 오늘 경기 정보가 없습니다. + )}
{/* Season Stats Section */}
- 2025 시즌 성적 - - - - - 타율 {stats?.summary.batting_avg_rank}위 - - WAR {stats?.summary.war_rank}위 - - 안타 {stats?.summary.hits_rank}위 - - 타점 {stats?.summary.rbi_rank}위 - - 홈런 {stats?.summary.home_run_rank}위 - - - - - - - 타율 - {stats?.metrics.batting_avg} - - - 홈런 - {stats?.metrics.home_runs} - - - 안타 - {stats?.metrics.hits} - - - 타점 - {stats?.metrics.rbi} - - - - - 득점 - {stats?.metrics.runs} - - - 도루 - {stats?.metrics.stolen_bases} - - - 출루율 - {stats?.metrics.on_base_percentage} - - - OPS - {stats?.metrics.ops} - - - - + {stats?.season ?? 2025} 시즌 성적 + {stats ? ( + + + + + WAR {stats.summary.war_rank}위 + + 타율 {stats.summary.batting_avg_rank}위 + + 안타 {stats.summary.hits_rank}위 + + 타점 {stats.summary.rbi_rank}위 + + 홈런 {stats.summary.home_run_rank}위 + + + + {stats.player_type === "BATTER" ? ( + + + + 타율 + {batterStats?.batting_avg} + + + 홈런 + {batterStats?.home_runs} + + + 안타 + {batterStats?.hits} + + + 타점 + {batterStats?.rbi} + + + + + 득점 + {batterStats?.runs} + + + 도루 + {batterStats?.stolen_bases} + + + 출루율 + {batterStats?.on_base_percentage} + + + OPS + {batterStats?.ops} + + + + ) : ( + + + + ERA + {pitcherStats?.era} + + + 경기 + {pitcherStats?.games} + + + 승패 + {pitcherStats?.win_loss} + + + 세이브/홀드 + {pitcherStats?.save_hold} + + + + + 이닝 + {pitcherStats?.innings_pitched} + + + 탈삼진 + {pitcherStats?.strikeouts} + + + 볼넷 + {pitcherStats?.walks} + + + WHIP + {pitcherStats?.whip} + + + + )} + + ) : ( + 시즌 성적 정보가 없습니다. + )}
{/* KBO Feed Section */} @@ -298,11 +444,14 @@ const TeamContainer = styled.div` gap: 4px; `; -const TeamLogo = styled.div` +const TeamLogo = styled.img` width: 64px; height: 64px; border-radius: ${theme.radius.s}; background: linear-gradient(135deg, #e0e0e0 0%, #f5f5f5 100%); + object-fit: contain; + padding: 6px; + box-sizing: border-box; `; const TeamName = styled.p` @@ -468,3 +617,12 @@ const LoadingText = styled.p` margin: 0; padding: 8px 0; `; + +const EmptyCard = styled.div` + background: ${theme.colors.white}; + border: 1px solid ${theme.colors.light02}; + border-radius: ${theme.radius.xl}; + padding: 20px 16px; + ${theme.typography.body03} + color: ${theme.colors.dark03}; +`; diff --git a/src/pages/OAuthRedirect.tsx b/src/pages/OAuthRedirect.tsx index 29aedc7..58430cb 100644 --- a/src/pages/OAuthRedirect.tsx +++ b/src/pages/OAuthRedirect.tsx @@ -1,24 +1,36 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; - -const OAuthRedirect = () => { - const navigate = useNavigate(); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const accessToken = params.get("accessToken"); - const refreshToken = params.get("refreshToken"); - - if (accessToken) { - localStorage.setItem("accessToken", accessToken); - localStorage.setItem("refreshToken", refreshToken ?? ""); - navigate("/"); - } else { - navigate("/login?error=true"); - } - }, [navigate]); - - return
로그인 처리 중...
; -}; - +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import api from '../api/instance'; + +const OAuthRedirect = () => { + const navigate = useNavigate(); + + useEffect(() => { + const handleRedirect = async () => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + + if (!accessToken) { + navigate('/login?error=true', { replace: true }); + return; + } + + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken ?? ''); + + try { + const favorites = (await api.get('v1/favorites').json()) as { playerIds: number[] }; + navigate(favorites.playerIds.length > 0 ? '/' : '/landing2', { replace: true }); + } catch { + navigate('/landing2', { replace: true }); + } + }; + + void handleRedirect(); + }, [navigate]); + + return
로그인 처리 중...
; +}; + export default OAuthRedirect;