From 5c02b803315b6cfc4a34bf45e28d1d991a05cd52 Mon Sep 17 00:00:00 2001 From: jam-jang Date: Fri, 6 Jun 2025 11:35:52 +0900 Subject: [PATCH] refact mypage file --- src/App.tsx | 6 +- src/pages/Profile/{components => }/Info.tsx | 155 ++++---- src/pages/Profile/MyPage.tsx | 5 +- src/pages/Profile/Profile.tsx | 330 ++++++++++++++++++ .../Profile/{components => }/ProfileEdit.tsx | 177 +++------- src/pages/Profile/components/BackHeader.tsx | 25 ++ .../Profile/components/ImageUploader.tsx | 52 +++ .../Profile/components/IntroductionField.tsx | 29 ++ .../Profile/components/NicknameField.tsx | 40 +++ src/pages/Profile/components/Profile.tsx | 249 ------------- src/pages/Profile/components/SubmitButton.tsx | 24 ++ src/pages/Profile/components/Teamselect.tsx | 36 ++ .../Profile/components/WithdrawModal.tsx | 47 +++ 13 files changed, 721 insertions(+), 454 deletions(-) rename src/pages/Profile/{components => }/Info.tsx (68%) create mode 100644 src/pages/Profile/Profile.tsx rename src/pages/Profile/{components => }/ProfileEdit.tsx (50%) create mode 100644 src/pages/Profile/components/BackHeader.tsx create mode 100644 src/pages/Profile/components/ImageUploader.tsx create mode 100644 src/pages/Profile/components/IntroductionField.tsx create mode 100644 src/pages/Profile/components/NicknameField.tsx delete mode 100644 src/pages/Profile/components/Profile.tsx create mode 100644 src/pages/Profile/components/SubmitButton.tsx create mode 100644 src/pages/Profile/components/Teamselect.tsx create mode 100644 src/pages/Profile/components/WithdrawModal.tsx 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/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..b8c5b06 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,17 @@ 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) { @@ -50,13 +46,13 @@ export default function ProfileEdit() { { credentials: "include", headers: { Authorization: `Bearer ${jwt}` }, - }, + } ); if (!res.ok) throw new Error(res.statusText); 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 +62,6 @@ export default function ProfileEdit() { })(); }, [navigate]); - // nickname check const checkNickname = async () => { if (!nickname.trim()) { alert("닉네임을 입력해주세요."); @@ -87,13 +82,13 @@ export default function ProfileEdit() { Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ nickname }), - }, + } ); 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 +99,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 () => { @@ -123,7 +116,7 @@ export default function ProfileEdit() { credentials: "include", headers: { Authorization: `Bearer ${jwt}` }, body: form, - }, + } ); if (!res.ok) throw new Error(res.statusText); const body = await res.json(); @@ -133,15 +126,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`, @@ -153,7 +145,7 @@ export default function ProfileEdit() { Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ nickname, bio: introduction, cheeringTeamId }), - }, + } ); if (!res.ok) { const err = await res.json(); @@ -168,104 +160,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" - /> - -
-
-
- -