diff --git a/src/App.tsx b/src/App.tsx index 0fb0c1e..b1eef51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,9 @@ import PlayerProfilePage from "./pages/playerProfile/PlayerProfilePage"; // 프로필(MyPage) import MyPage from "./pages/Profile/MyPage"; -import Profile from "./pages/Profile/components/Profile"; -import Info from "./pages/Profile/components/Info"; -import ProfileEdit from "./pages/Profile/components/ProfileEdit"; +import Profile from "./pages/Profile/Profile"; +import Info from "./pages/Profile/Info"; +import ProfileEdit from "./pages/Profile/ProfileEdit"; // 매칭 import MatchingGameListPage from "./pages/Matching/MatchingGameListPage"; diff --git a/src/api/ai/apis.tsx b/src/api/ai/apis.tsx index 097a6ea..4e76ef7 100644 --- a/src/api/ai/apis.tsx +++ b/src/api/ai/apis.tsx @@ -1,5 +1,9 @@ import axiosInstance from "../axiosInstance"; -import { AIPredictionResponse } from "../../types/ai"; +import { + AIPredictionResponse, + FinalRankingResponse, + GamePredictionResponse, +} from "../../types/ai"; export const aiApi = { getWinProbability: async (date: string): Promise => { @@ -12,4 +16,29 @@ export const aiApi = { winProbability: item.winProbability, })); }, + getFinalRank: async (): Promise => { + const { data: raw } = + await axiosInstance.get("/final-rankings"); + return raw.map((item: any) => ({ + id: item.id, + teamName: item.teamName, + rank: item.rank, + currentRank: item.currentRank, + })); + }, + getGamePrediction: async (): Promise => { + const { data: raw } = await axiosInstance.get( + "/api/win-rates/live-prediction", + ); + return raw.map((item: any) => ({ + gameId: item.gameId, + awayTeam: item.awayTeam, + homeTeam: item.homeTeam, + inning: item.inning, + winProbability: item.winProbability, + homeAccumScore: item.homeAccumScore, + awayAccumScore: item.awayAccumScore, + predictedAt: item.predictedAt, + })); + }, }; diff --git a/src/api/ranking/apis.tsx b/src/api/ranking/apis.tsx index 2964252..a5ca6d3 100644 --- a/src/api/ranking/apis.tsx +++ b/src/api/ranking/apis.tsx @@ -4,6 +4,10 @@ import { PitcherSaveDto, PitcherStrikeoutDto, PitcherWinRateDto, + PlayerAVGDto, + PlayerHitDto, + PlayerHRDto, + PlayerRBIDto, RankingDto, } from "../../types/ranking"; import { @@ -41,7 +45,7 @@ export const rankingApi = { return response.data.data.map(item => ({ playerName: item.playerName, backNumber: item.backNumber, - wpct: item.wpct, + wpct: item.value, playerImageUrl: item.playerImageUrl, })); }, @@ -52,7 +56,7 @@ export const rankingApi = { return response.data.data.map(item => ({ playerName: item.playerName, backNumber: item.backNumber, - sv: item.sv, + sv: item.value, playerImageUrl: item.playerImageUrl, })); }, @@ -63,7 +67,7 @@ export const rankingApi = { return response.data.data.map(item => ({ playerName: item.playerName, backNumber: item.backNumber, - so: item.so, + so: item.value, playerImageUrl: item.playerImageUrl, })); }, @@ -74,7 +78,51 @@ export const rankingApi = { return response.data.data.map(item => ({ playerName: item.playerName, backNumber: item.backNumber, - era: item.era, + era: item.value, + playerImageUrl: item.playerImageUrl, + })); + }, + getRankPlayerHR: async () => { + const response = await axiosInstance.get( + "/record/personalRank/hitter/HR", + ); + return response.data.data.map(item => ({ + playerName: item.playerName, + backNumber: item.backNumber, + hr: item.value, + playerImageUrl: item.playerImageUrl, + })); + }, + getRankPlayerRBI: async () => { + const response = await axiosInstance.get( + "/record/personalRank/hitter/rbi", + ); + return response.data.data.map(item => ({ + playerName: item.playerName, + backNumber: item.backNumber, + rbi: item.value, + playerImageUrl: item.playerImageUrl, + })); + }, + getRankPlayerAVG: async () => { + const response = await axiosInstance.get( + "/record/personalRank/hitter/avg", + ); + return response.data.data.map(item => ({ + playerName: item.playerName, + backNumber: item.backNumber, + avg: item.value, + playerImageUrl: item.playerImageUrl, + })); + }, + getRankPlayerHit: async () => { + const response = await axiosInstance.get( + "/record/personalRank/hitter/H", + ); + return response.data.data.map(item => ({ + playerName: item.playerName, + backNumber: item.backNumber, + hit: item.value, playerImageUrl: item.playerImageUrl, })); }, diff --git a/src/pages/Home/components/KoreaMap.tsx b/src/pages/Home/components/KoreaMap.tsx index 88021ef..a64d8f6 100644 --- a/src/pages/Home/components/KoreaMap.tsx +++ b/src/pages/Home/components/KoreaMap.tsx @@ -307,6 +307,10 @@ export default function KoreaMap({ )} + {/* 데이터 출처 */} +
+ (공공데이터 포털 한국환경공단 데이터를 사용 중입니다.) +
); diff --git a/src/pages/Login/LoginPage.tsx b/src/pages/Login/LoginPage.tsx index 2845eee..3e9e8b3 100644 --- a/src/pages/Login/LoginPage.tsx +++ b/src/pages/Login/LoginPage.tsx @@ -14,12 +14,9 @@ interface KakaoUserInfoResponse { success: boolean; message: string; data: { - accessToken: string; + accessToken: string; // 백엔드가 내려주는 JWT 토큰 tokenType: string; expiresIn: number; - id?: number; - has_signed_up?: boolean; - properties?: { nickname: string }; }; } @@ -27,27 +24,27 @@ const LoginPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - // 이미 jwtToken이 있으면 /login(id === undefined)이 아니라 바로 홈으로 + // 이미 jwtToken이 있으면 id(param)가 undefined인 순수 로그인 화면이 아니라 곧바로 홈으로 리다이렉트 useEffect(() => { const token = localStorage.getItem("jwtToken"); if (!id && token) { window.location.href = "/"; } - }, [id, navigate]); + }, [id]); - // 카카오 SDK 초기화 + // 카카오 SDK 초기화 (페이지 로드 시 한 번만 수행) useEffect(() => { if (window.Kakao && !window.Kakao.isInitialized()) { window.Kakao.init(import.meta.env.VITE_KAKAO_JS_KEY); } }, []); + // 회원가입 1단계(닉네임 입력)과 2단계(팀 선택)를 위한 상태 const [nickname, setNickname] = useState(""); - const [nicknameCheckResult, setNicknameCheckResult] = useState< - "available" | "duplicate" | null - >(null); + const [nicknameCheckResult, setNicknameCheckResult] = useState<"available" | "duplicate" | null>(null); const [selectedTeam, setSelectedTeam] = useState(null); + // 카카오 로그인 버튼 클릭 시 실행될 함수 const kakaoLogin = () => { if (!window.Kakao) { return alert("카카오 SDK가 로드되지 않았습니다."); @@ -58,26 +55,27 @@ const LoginPage: React.FC = () => { throughTalk: false, success: async (authObj: any) => { try { - // 1) 카카오 accessToken으로 백엔드 로그인 + // 1) 카카오 access_token을 백엔드로 전달하여 우리 서버에서 JWT 발급받기 const res = await fetch( `${import.meta.env.VITE_API_URL}/api/kakao/user-info`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessToken: authObj.access_token }), - }, + } ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + // 2) 백엔드가 내려준 JSON 파싱 const resp = (await res.json()) as KakaoUserInfoResponse; - // 2) JWT 저장 + // 3) JWT를 로컬스토리지에 저장 const jwt = resp.data.accessToken; localStorage.setItem("jwtToken", jwt); - if (resp.data.id !== undefined) { - localStorage.setItem("userIdx", String(resp.data.id)); - } - // 3) 가입 여부 조회 (/login/hasSignedIn) + // 4) 가입 여부 조회 (hasSignedIn) const hasRes = await fetch( `${import.meta.env.VITE_API_URL}/login/hasSignedIn`, { @@ -86,25 +84,24 @@ const LoginPage: React.FC = () => { "Content-Type": "application/json", Authorization: `Bearer ${jwt}`, }, - }, + } ); - if (!hasRes.ok) + if (!hasRes.ok) { throw new Error(`hasSignedIn 호출 실패: ${hasRes.status}`); - const hasJson = (await hasRes.json()) as { + } + + const hasJson = await hasRes.json() as { success: boolean; message: string; data: { hasSignedIn: boolean }; }; - // 4) 분기 처리 + // 5) 분기 처리 if (hasJson.data.hasSignedIn) { - // 이미 모두 완료된 회원: 홈으로 + // 이미 가입된 회원: 홈으로 이동 window.location.href = "/"; } else { - // 닉네임/응원팀 설정 필요 - if (resp.data.properties?.nickname) { - setNickname(resp.data.properties.nickname); - } + // 신규 회원: 닉네임/응원팀 설정 단계로 이동 navigate("/signup/1"); } } catch (err) { @@ -119,30 +116,37 @@ const LoginPage: React.FC = () => { }); }; + // 회원 가입 단계별 “다음” 버튼 핸들러 const goStep2 = () => { - if (nicknameCheckResult === "available") navigate("/signup/2"); - else alert("닉네임 중복 체크를 완료해주세요!"); + if (nicknameCheckResult === "available") { + navigate("/signup/2"); + } else { + alert("닉네임 중복 체크를 완료해주세요!"); + } }; const goStep3 = () => { - if (selectedTeam) navigate("/signup/3"); - else alert("응원팀을 선택해주세요!"); + if (selectedTeam) { + navigate("/signup/3"); + } else { + alert("응원팀을 선택해주세요!"); + } }; - // id 파라미터 없으면 순수 로그인 화면 + // id 파라미터가 없으면 순수 로그인 화면을 렌더링 if (!id) { return (

야구를 더 가까이, 더 즐겁게

DUGOUT

+
- - SNS로 간편 로그인 - + SNS로 간편 로그인
+
+

계속 진행 시{" "} diff --git a/src/pages/Prediction/components/LivePrediction.tsx b/src/pages/Prediction/components/LivePrediction.tsx index 4bd6151..48130d0 100644 --- a/src/pages/Prediction/components/LivePrediction.tsx +++ b/src/pages/Prediction/components/LivePrediction.tsx @@ -1,53 +1,23 @@ import { Zap, RefreshCw } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { PieChart, Pie, Cell } from "recharts"; +import { aiApi } from "../../../api/ai/apis"; +import { getEnglishTeamName } from "../../../hooks/TeamNameChanger"; interface Matchup { leftTeam: { name: string; logo: string; percent: number; + score: number; }; rightTeam: { name: string; logo: string; percent: number; + score: number; }; -} - -const initialMatchups: Matchup[] = [ - { - leftTeam: { name: "두산", logo: "doosan", percent: 32 }, - rightTeam: { name: "KIA", logo: "kia", percent: 68 }, - }, - { - leftTeam: { name: "SSG", logo: "ssg", percent: 31 }, - rightTeam: { name: "삼성", logo: "samsung", percent: 69 }, - }, - { - leftTeam: { name: "롯데", logo: "lotte", percent: 73 }, - rightTeam: { name: "키움", logo: "kiwoom", percent: 27 }, - }, - { - leftTeam: { name: "NC", logo: "nc", percent: 35 }, - rightTeam: { name: "LG", logo: "lg", percent: 65 }, - }, - { - leftTeam: { name: "한화", logo: "hanwha", percent: 75 }, - rightTeam: { name: "KT", logo: "kt", percent: 25 }, - }, -]; - -function getRandomizedMatchups() { - return initialMatchups.map(m => { - const left = Math.floor(Math.random() * 71) + 15; - const right = 100 - left; - return { - ...m, - leftTeam: { ...m.leftTeam, percent: left }, - rightTeam: { ...m.rightTeam, percent: right }, - }; - }); + inning: number; } // 팀별 색상 가져오기 함수 @@ -64,11 +34,77 @@ function getTeamColor(teamLogo: string) { } export default function LivePrediction() { - const [matchups, setMatchups] = useState(initialMatchups); + const [matchups, setMatchups] = useState([]); const [selectedIdx, setSelectedIdx] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [cooldown, setCooldown] = useState(0); + const [lastManualRefresh, setLastManualRefresh] = useState(0); + + const fetchPredictions = useCallback( + async (isManualRefresh = false) => { + // 수동 새로고침인 경우 쿨다운 체크 + if (isManualRefresh) { + const now = Date.now(); + if (now - lastManualRefresh < 10000) { + // 10초 쿨다운 + return; + } + setLastManualRefresh(now); + setCooldown(10); + } + + try { + setIsLoading(true); + setError(null); + const data = await aiApi.getGamePrediction(); + + const formattedMatchups: Matchup[] = data.map(game => ({ + leftTeam: { + name: game.awayTeam, + logo: getEnglishTeamName(game.awayTeam), + percent: Math.round((1 - game.winProbability) * 100), + score: game.awayAccumScore, + }, + rightTeam: { + name: game.homeTeam, + logo: getEnglishTeamName(game.homeTeam), + percent: Math.round(game.winProbability * 100), + score: game.homeAccumScore, + }, + inning: game.inning, + })); + + setMatchups(formattedMatchups); + } catch (err) { + setError("데이터를 불러오는데 실패했습니다."); + console.error("Failed to fetch predictions:", err); + } finally { + setIsLoading(false); + } + }, + [lastManualRefresh], + ); + + useEffect(() => { + fetchPredictions(); + // 1분마다 자동 갱신 + const interval = setInterval(() => fetchPredictions(false), 60000); + return () => clearInterval(interval); + }, [fetchPredictions]); + + // 쿨다운 타이머 + useEffect(() => { + if (cooldown > 0) { + const timer = setInterval(() => { + setCooldown(prev => prev - 1); + }, 1000); + return () => clearInterval(timer); + } + }, [cooldown]); const handleRefresh = () => { - setMatchups(getRandomizedMatchups()); + fetchPredictions(true); setSelectedIdx(null); }; @@ -149,70 +185,104 @@ export default function LivePrediction() {

-

- 현재 진행 중인 경기의 승부 예측 결과입니다. +

+ AI가 분석한 실시간 승률입니다. 이닝 종료 시 자동 갱신됩니다.

-
- {matchups.map((match, idx) => { - const leftWin = match.leftTeam.percent > match.rightTeam.percent; - return ( -
setSelectedIdx(idx)} - > - {/* 왼쪽 팀 */} -
- - {match.leftTeam.name} - - {match.leftTeam.name} -
- {/* 확률 */} -
- - {match.leftTeam.percent}% - - - {match.rightTeam.percent}% - -
- {/* 오른쪽 팀 */} -
- {match.rightTeam.name} - - {match.rightTeam.name} - + {error ? ( +
+ {error} +
+ ) : isLoading ? ( +
+ +
+ ) : ( +
+ {matchups.slice(0, 5).map((match, idx) => { + return ( +
setSelectedIdx(idx)} + > + {/* 왼쪽 팀 */} +
+ + {match.leftTeam.name} + + {match.leftTeam.name} + + {match.leftTeam.score} + +
+ + {/* 이닝 표시 */} +
+
+ + {match.inning}회 + +
+
+ + {/* 오른쪽 팀 */} +
+ + {match.rightTeam.score} + + {match.rightTeam.name} + + {match.rightTeam.name} + +
-
- ); - })} -
+ ); + })} +
+ )} {/* 도넛 차트 */} {selected && (
-
+
{winnerText}
- + -
+
([]); + const [isLoading, setIsLoading] = useState(true); -// 임시 데이터 - 실제로는 API에서 가져올 데이터 -const mockTeams: Team[] = [ - { id: 1, name: "SSG 랜더스", logo: "ssg", prediction: 1 }, - { id: 2, name: "키움 히어로즈", logo: "kiwoom", prediction: 2 }, - { id: 3, name: "LG 트윈스", logo: "lg", prediction: 3 }, - { id: 4, name: "KT 위즈", logo: "kt", prediction: 4 }, - { id: 5, name: "KIA 타이거즈", logo: "kia", prediction: 5 }, - { id: 6, name: "NC 다이노스", logo: "nc", prediction: 6 }, - { id: 7, name: "두산 베어스", logo: "doosan", prediction: 7 }, - { id: 8, name: "롯데 자이언츠", logo: "lotte", prediction: 8 }, - { id: 9, name: "삼성 라이온즈", logo: "samsung", prediction: 9 }, - { id: 10, name: "한화 이글스", logo: "hanwha", prediction: 10 }, -]; + useEffect(() => { + const fetchTeams = async () => { + try { + const data = await aiApi.getFinalRank(); + setTeams(data); + } catch (error) { + console.error("팀 순위 데이터를 불러오는데 실패했습니다:", error); + } finally { + setIsLoading(false); + } + }; + + fetchTeams(); + }, []); + + if (isLoading) { + return ( +
+
+

로딩 중...

+
+
+ ); + } -export default function WinnerPrediction() { return (
@@ -40,30 +51,26 @@ export default function WinnerPrediction() { 현재 순위 - 승률 - {mockTeams.map(team => ( + {teams.map(team => ( - {team.prediction} + {team.rank} {team.name} - {team.name} - - - {team.prediction} + {team.teamName} - {(Math.random() * 0.3 + 0.4).toFixed(3)} + {team.currentRank} ))} diff --git a/src/pages/Profile/components/Info.tsx b/src/pages/Profile/Info.tsx similarity index 68% rename from src/pages/Profile/components/Info.tsx rename to src/pages/Profile/Info.tsx index 4d84824..8a660c4 100644 --- a/src/pages/Profile/components/Info.tsx +++ b/src/pages/Profile/Info.tsx @@ -1,6 +1,8 @@ +// src/pages/Profile/Info.tsx import { useEffect, useState } from "react"; -import { ChevronLeft, X } from "lucide-react"; import { useNavigate } from "react-router-dom"; +import BackHeader from "./components/BackHeader"; +import { X } from "lucide-react"; interface ProfileInfo { nickname: string; @@ -26,6 +28,7 @@ export default function Info() { gender: 0, }); + // 서버에서 내 프로필 정보 가져오기 useEffect(() => { const jwt = localStorage.getItem("jwtToken"); if (!jwt) { @@ -39,7 +42,7 @@ export default function Info() { `${import.meta.env.VITE_API_URL}/mypage/myTemp`, { headers: { Authorization: `Bearer ${jwt}` }, - }, + } ); if (!res.ok) throw new Error(`HTTP error ${res.status}`); const data = await res.json(); @@ -86,7 +89,7 @@ export default function Info() { Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(body), - }, + } ); if (!res.ok) { const errJson = await res.json().catch(() => null); @@ -108,7 +111,6 @@ export default function Info() { setIsEditingBirthday(false); setIsEditingPhone(false); setIsEditingGender(false); - // 필요 시, 원래 값으로 복원하려면 fetch 다시 호출 }; const handleConfirmWithdrawal = async () => { @@ -153,17 +155,16 @@ export default function Info() { const { nickname, email, birthday, phone, gender } = profile; return ( -
-
- + <> + {/* ─── 최상위 래퍼: 화면을 채우고 회색 배경으로 만듭니다 ─── */} +
+ {/* ① BackHeader를 화면 왼쪽 상단에 고정 (절대 위치) */} +
+ +
-
+ {/* ② 메인 콘텐츠: 흰색 카드 없이 */} +
{/* 이름 */}
@@ -174,14 +175,15 @@ export default function Info() { className="w-full cursor-not-allowed rounded border border-gray-300 bg-gray-100 p-3" />
+ {/* 생년월일 */}
- setProfile(prev => ({ ...prev, birthday: e.target.value })) + onChange={(e) => + setProfile((prev) => ({ ...prev, birthday: e.target.value })) } readOnly={!isEditingBirthday} className={`w-full rounded border border-gray-300 p-3 ${ @@ -189,6 +191,7 @@ export default function Info() { }`} />
+ {/* 성별 */}
@@ -200,7 +203,7 @@ export default function Info() { name="gender" checked={gender === 0} onChange={() => - setProfile(prev => ({ ...prev, gender: 0 })) + setProfile((prev) => ({ ...prev, gender: 0 })) } className="mr-2" /> @@ -212,7 +215,7 @@ export default function Info() { name="gender" checked={gender === 1} onChange={() => - setProfile(prev => ({ ...prev, gender: 1 })) + setProfile((prev) => ({ ...prev, gender: 1 })) } className="mr-2" /> @@ -228,6 +231,7 @@ export default function Info() { /> )}
+ {/* 이메일 */}
@@ -238,14 +242,15 @@ export default function Info() { className="w-full cursor-not-allowed rounded border border-gray-300 bg-gray-100 p-3" />
+ {/* 전화번호 */}
- setProfile(prev => ({ ...prev, phone: e.target.value })) + onChange={(e) => + setProfile((prev) => ({ ...prev, phone: e.target.value })) } readOnly={!isEditingPhone} className={`w-full rounded border border-gray-300 p-3 ${ @@ -253,77 +258,77 @@ export default function Info() { }`} />
-
- {/* 버튼 그룹 */} -
- {isEditing ? ( - <> - + {/* 버튼 그룹 */} +
+ {isEditing ? ( + <> + + + + ) : ( - - ) : ( + )} - )} - +
-
- {/* 회원탈퇴 모달 */} - {isModalOpen && ( -
-
- -

회원 탈퇴

-

- 탈퇴 시, 모든 데이터가 영구적으로 사라집니다. -

-
+ {/* 회원탈퇴 모달 (흰 배경 카드 그대로 유지) */} + {isModalOpen && ( +
+
- +

회원 탈퇴

+

+ 탈퇴 시, 모든 데이터가 영구적으로 사라집니다. +

+
+ + +
-
- )} -
+ )} +
+ ); } diff --git a/src/pages/Profile/MyPage.tsx b/src/pages/Profile/MyPage.tsx index e6e6384..74f2193 100644 --- a/src/pages/Profile/MyPage.tsx +++ b/src/pages/Profile/MyPage.tsx @@ -4,9 +4,8 @@ import { Outlet } from "react-router-dom"; export default function MyPage() { return (
- {/* ───── 컨테이너 (공통 레이아웃) ───── */} -
- {/* 자식 라우트(Profile / Info / ProfileEdit)가 이 자리에 그려집니다 */} + +
diff --git a/src/pages/Profile/Profile.tsx b/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..aac5417 --- /dev/null +++ b/src/pages/Profile/Profile.tsx @@ -0,0 +1,330 @@ +// src/pages/Profile/Profile.tsx +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + getTeamLogoByIdx, + getTeamNameByIdx, +} from "../../hooks/TeamNameChanger"; // 실제 경로에 맞게 수정해주세요 + +interface ProfileData { + userIdx: number; + nickname: string; + cheeringTeamId: number; + bio: string; + profileImageUrl: string; + userTemp: number; +} + +interface MyMatchingPostDto { + matchingPostIdx: number; + userIdx: number; + gameIdx: number; + stadiumIdx: number; + teamIdx: number; + title: string; + context: string; + haveTicket: boolean; + createdAt: string; + isMatched: boolean; // 매칭 완료 여부 +} + +export default function Profile() { + const navigate = useNavigate(); + + // 프로필 정보 상태 + const [profile, setProfile] = useState(null); + // 내가 쓴 매칭 글 상태 + const [myPosts, setMyPosts] = useState([]); + const [isLoadingPosts, setIsLoadingPosts] = useState(false); + const [postsError, setPostsError] = useState(null); + + useEffect(() => { + (async () => { + try { + const token = localStorage.getItem("jwtToken"); + if (!token) throw new Error("토큰이 없습니다."); + + // 1) 프로필 정보 조회 + const profileRes = await fetch( + `${import.meta.env.VITE_API_URL}/mypage/myTemp`, + { + method: "GET", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + } + ); + if (!profileRes.ok) + throw new Error(`프로필 조회 실패: ${profileRes.status}`); + const profileData: ProfileData = await profileRes.json(); + setProfile(profileData); + + // 2) 내가 쓴 매칭 글 목록 조회 (/mypage/myPost) + setIsLoadingPosts(true); + setPostsError(null); + + const postsRes = await fetch( + `${import.meta.env.VITE_API_URL}/mypage/myPost`, + { + method: "GET", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + } + ); + if (!postsRes.ok) + throw new Error(`내 글 조회 실패: ${postsRes.status}`); + const postsData: MyMatchingPostDto[] = await postsRes.json(); + setMyPosts(postsData); + } catch (err: any) { + console.error("프로필 또는 매칭 글 불러오기 실패:", err); + setPostsError("내가 쓴 매칭 글을 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoadingPosts(false); + } + })(); + }, []); + + // 팀명/로고 파생 값 + const cheeringTeamName = + profile?.cheeringTeamId != null + ? getTeamNameByIdx(profile.cheeringTeamId) + : "로딩 중"; + const cheeringTeamLogo = + profile?.cheeringTeamId != null + ? getTeamLogoByIdx(profile.cheeringTeamId) + : "/images/default_team_emb.png"; + + /** + * 매칭 완료 토글 함수 + * @param postId {number} - 토글할 matchingPostIdx + */ + const handleToggleMatched = async (postId: number) => { + try { + const token = localStorage.getItem("jwtToken"); + if (!token) throw new Error("토큰이 없습니다."); + + // PATCH 요청: is_matched 상태 토글 + const res = await fetch( + `${import.meta.env.VITE_API_URL}/mypage/${postId}/toggle-matched`, + { + method: "PATCH", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + } + ); + if (!res.ok) { + throw new Error(`매칭 토글 실패: ${res.status}`); + } + const updated = await res.json(); + // 예시 응답: { matchingPostIdx: 3, matched: true } + + // 로컬 상태에서도 해당 게시글의 isMatched 값을 반전시켜서 업데이트 + setMyPosts((prev) => + prev.map((p) => + p.matchingPostIdx === updated.matchingPostIdx + ? { ...p, isMatched: updated.matched } + : p + ) + ); + } catch (error: any) { + console.error("매칭 토글 중 오류 발생:", error); + alert(`오류: ${error.message}`); + } + }; + + return ( + <> +
+ {/* 헤더 배너 */} +
+
+
+ {/* 프로필 + 소개 */} +
+ 0 + ? profile.profileImageUrl + : "/images/user_avatar.png" + } + alt="프로필" + className="h-24 w-24 rounded-full border-4 border-white object-cover" + onError={(e) => { + e.currentTarget.src = "/images/user_avatar.png"; + }} + /> +
+
+

+ {profile?.nickname || "로딩 중..."} +

+ + {cheeringTeamName} + +
+

+ {profile?.bio || "자기소개가 없습니다."} +

+ {/* 수정 / 설정 버튼 */} +
+ + +
+
+
+ {/* 팀 엠블럼 */} +
+ 팀 엠블럼 { + e.currentTarget.src = "/images/default_team_emb.png"; + }} + /> +
+
+
+ + {/* 직관 온도 */} +
+
+ + 직관 온도 + + + {profile?.userTemp != null + ? `${profile.userTemp.toFixed(1)}℃` + : "–"} + +
+
+
+
+
+ + {/* 작성한 매칭 글 (버튼 추가) */} +
+

+ 작성한 매칭 글 +

+ {isLoadingPosts && ( +

불러오는 중...

+ )} + {postsError && ( +

{postsError}

+ )} + {!isLoadingPosts && !postsError && myPosts.length === 0 && ( +

+ 작성한 매칭 글이 없습니다. +

+ )} + {!isLoadingPosts && !postsError && myPosts.length > 0 && ( +
+ {myPosts.map((post) => ( +
+
+ navigate(`/matching/articles/${post.matchingPostIdx}`) + } + > +
+
+
+ + {profile?.nickname || ""} ·{" "} + {new Date(post.createdAt).toLocaleDateString( + "ko-KR" + )} + +
+

+ {post.title} +

+

+ {post.context.length > 30 + ? post.context.slice(0, 30) + "…" + : post.context} +

+
+
+
+ + {cheeringTeamName} + + + {post.haveTicket ? "티켓 있음" : "티켓 없음"} + +
+ + {new Date(post.createdAt).toLocaleDateString("ko-KR")} + +
+
+ + {/** + * ▷ 매칭 완료 토글 버튼 + * - post.isMatched === false → “매칭 완료” + * - post.isMatched === true → “다시 매칭하기” + */} + +
+ ))} +
+ )} +
+
+ + ); +} diff --git a/src/pages/Profile/components/ProfileEdit.tsx b/src/pages/Profile/ProfileEdit.tsx similarity index 50% rename from src/pages/Profile/components/ProfileEdit.tsx rename to src/pages/Profile/ProfileEdit.tsx index a46a3bb..5e0e4bf 100644 --- a/src/pages/Profile/components/ProfileEdit.tsx +++ b/src/pages/Profile/ProfileEdit.tsx @@ -1,9 +1,14 @@ -// src/pages/Profile/components/ProfileEdit.tsx +// src/pages/Profile/ProfileEdit.tsx import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { ChevronLeft } from "lucide-react"; +import BackHeader from "./components/BackHeader"; +import ImageUploader from "./components/ImageUploader"; +import NicknameField from "./components/NicknameField"; +import IntroductionField from "./components/IntroductionField"; +import TeamSelect from "./components/Teamselect"; +import SubmitButton from "./components/SubmitButton"; -const teams = [ +const teamsList = [ "LG 트윈스", "SSG 랜더스", "삼성 라이온즈", @@ -16,26 +21,19 @@ const teams = [ "한화 이글스", ]; -enum CheckResult { - None = "none", - Available = "available", - Duplicate = "duplicate", -} - export default function ProfileEdit() { const navigate = useNavigate(); - // personal info const [nickname, setNickname] = useState(""); - const [checkResult, setCheckResult] = useState(CheckResult.None); + const [checkResult, setCheckResult] = useState< + "none" | "available" | "duplicate" + >("none"); const [introduction, setIntroduction] = useState(""); - const [team, setTeam] = useState(teams[0]); + const [team, setTeam] = useState(teamsList[0]); - // image upload state const [file, setFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); - // existing profile load useEffect(() => { const jwt = localStorage.getItem("jwtToken"); if (!jwt) { @@ -56,7 +54,7 @@ export default function ProfileEdit() { const data = await res.json(); setNickname(data.nickname); setIntroduction(data.bio); - setTeam(teams[data.cheeringTeamId - 1]); + setTeam(teamsList[data.cheeringTeamId - 1]); setPreviewUrl(data.profileImageUrl); } catch (err) { console.error("프로필 조회 실패:", err); @@ -66,7 +64,6 @@ export default function ProfileEdit() { })(); }, [navigate]); - // nickname check const checkNickname = async () => { if (!nickname.trim()) { alert("닉네임을 입력해주세요."); @@ -90,10 +87,10 @@ export default function ProfileEdit() { }, ); if (res.status === 200) { - setCheckResult(CheckResult.Available); + setCheckResult("available"); alert("사용 가능한 닉네임입니다."); } else if (res.status === 400) { - setCheckResult(CheckResult.Duplicate); + setCheckResult("duplicate"); alert("이미 사용 중인 닉네임입니다."); } else { throw new Error(res.statusText); @@ -104,11 +101,9 @@ export default function ProfileEdit() { } }; - // file preview - const handleFileChange = (e: React.ChangeEvent) => { - const f = e.target.files?.[0] ?? null; + const handleFileChange = (f: File) => { setFile(f); - if (f) setPreviewUrl(URL.createObjectURL(f)); + setPreviewUrl(URL.createObjectURL(f)); }; const handleImageUpload = async () => { @@ -133,15 +128,14 @@ export default function ProfileEdit() { navigate("/mypage", { state: { newImageUrl: newUrl } }); }; - // submit const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (checkResult !== CheckResult.Available) { + if (checkResult !== "available") { return alert("닉네임 중복 확인을 해주세요."); } const jwt = localStorage.getItem("jwtToken"); if (!jwt) return alert("로그인 정보가 유효하지 않습니다."); - const cheeringTeamId = teams.indexOf(team) + 1; + const cheeringTeamId = teamsList.indexOf(team) + 1; try { const res = await fetch( `${import.meta.env.VITE_API_URL}/mypage/editPersonal`, @@ -168,104 +162,41 @@ export default function ProfileEdit() { }; return ( -
-
- -
-
-
- 프로필 + {/* 1) BackHeader: 절대 위치로 화면 왼쪽 상단에 고정 */} +
+ +
+ + {/* 2) 메인 컨테이너: 화면 중앙에 너비 제한 없이 채움 */} +
+ {/* 3) 카드 배경 없이 바로 폼 콘텐츠 */} +
+ {/* ▷ 이미지 업로더 */} +
+ +
+ + {/* ▷ 입력 폼 */} +
+
+ -
- - + + +
-
-
- -
- { - setNickname(e.target.value); - setCheckResult(CheckResult.None); - }} - className="flex-1 rounded-l border px-3 py-2" - /> - -
-
-
- -