diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 6245bde..2f3bd40 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -3,7 +3,7 @@ import React from "react"; import Logo from "/images/blue.png"; import { Link, useLocation } from "react-router-dom"; -import { Bell, User2 } from "lucide-react"; +import { User2, MessageCircle } from "lucide-react"; const Header: React.FC = () => { const location = useLocation(); @@ -79,7 +79,12 @@ const Header: React.FC = () => { > 로그아웃 - + + + (); const navigate = useNavigate(); - - // 2) 쿼리스트링에서 page 값을 읽어옴 (백엔드 API가 0-indexed page를 기대한다고 가정) const [searchParams] = useSearchParams(); const pageParam = searchParams.get("page") || "0"; - // 화면에 보여줄 때는 1-based page 번호로 사용 const [currentPage, setCurrentPage] = useState( Number(pageParam) + 1 ); - // 3) API에서 받아온 매칭 글 목록 데이터 const [listData, setListData] = useState([]); - // 4) API 호출 중인 상태 + const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(false); - // 5) 에러 메시지 const [errorMsg, setErrorMsg] = useState(null); - // 6) 페이지네이션 메타에서 내려오는 전체 페이지 수 - const [totalPages, setTotalPages] = useState(1); - // ──────────────────────────────────────────────────────────── - // useEffect: gameIdx 또는 currentPage가 바뀔 때마다 API 호출 + // 검색어 하이라이트 + const [searchTerm, setSearchTerm] = useState(""); + + // ─── Selected Game 정보 ──────────────────────────────────── + const [selectedGame, setSelectedGame] = useState(null); useEffect(() => { - const fetchMatchingPosts = async () => { - if (!gameIdx) { - setErrorMsg("유효한 게임 ID가 없습니다."); - setListData([]); - setTotalPages(1); - return; - } + if (!date || !gameIdx) { + setSelectedGame(null); + return; + } + const [yyyy, mm, dd] = date.split("-"); + axios + .get("/home/calendar-games", { + params: { month: `${yyyy}-${mm}`, day: Number(dd) }, + }) + .then((resp) => { + const today = resp.data.days.find((d) => d.day === Number(dd)); + const game = today?.games.find( + (g) => String(g.gameIdx) === String(gameIdx) + ); + setSelectedGame(game ?? null); + }) + .catch(() => { + setSelectedGame(null); + }); + }, [date, gameIdx]); - setIsLoading(true); - setErrorMsg(null); + // 로고 + 시간 계산 + const homeLogoUrl = selectedGame + ? `/images/${getEnglishTeamName(selectedGame.homeTeamName)}_emb.png` + : ""; + const awayLogoUrl = selectedGame + ? `/images/${getEnglishTeamName(selectedGame.awayTeamName)}_emb.png` + : ""; + const startTime = selectedGame?.startTime ?? ""; - try { - // GET /matching-post/by-game/{gameIdx}?page={currentPage - 1} - // (백엔드는 0-indexed 페이지를 기대한다고 가정) - const response = await axios.get( - `/matching-post/by-game/${gameIdx}`, - { - params: { - page: currentPage - 1, - }, - } - ); + // ─── Matching Posts 조회 ──────────────────────────────────── + useEffect(() => { + if (!gameIdx) return; + setIsLoading(true); + setErrorMsg(null); - const data = response.data; - // ─────────────────────────────────────────────────────── - // (1) 만약 response.data가 배열(MatchingPostDto[])로 내려온다면 + axios + .get( + `/matching-post/by-game/${gameIdx}`, + { params: { page: currentPage - 1 } } + ) + .then((res) => { + const data = res.data; if (Array.isArray(data)) { - setListData(data as MatchingPostDto[]); + setListData(data); setTotalPages(1); - } - // (2) response.data.posts 형태(메타+배열)로 내려온다면 - else if ((data as MatchingListResponse).posts !== undefined) { - const typed = data as MatchingListResponse; - setListData(typed.posts); - setTotalPages(typed.totalPages); - } - // (3) 그 외의 예외: 빈 배열로 초기화 - else { + } else if ("posts" in data) { + setListData(data.posts); + setTotalPages(data.totalPages); + } else { setListData([]); setTotalPages(1); } - // ─────────────────────────────────────────────────────── - } catch (err) { - console.error("게임별 매칭 글 조회 실패:", err); + }) + .catch(() => { setErrorMsg("게시글을 불러오는 중 오류가 발생했습니다."); setListData([]); setTotalPages(1); - } finally { - setIsLoading(false); - } - }; - - fetchMatchingPosts(); + }) + .finally(() => setIsLoading(false)); }, [gameIdx, currentPage]); - // ──────────────────────────────────────────────────────────── - // “매칭 제안하기” 버튼 클릭 시 - const handleWriteClick = () => { - navigate("/matching/write"); - }; - - // 개별 게시글 클릭 시 상세 페이지로 이동 - const handleRowClick = (postIdx: number) => { - navigate(`/matching/articles/${postIdx}`); - }; - - // 페이지 번호 클릭 시 호출 const goToPage = (page: number) => { setCurrentPage(page); - // URL 쿼리스트링에 ?page={page - 1} 반영 navigate({ pathname: `/matching/list/${date}/${team}/${gameIdx}`, search: `?page=${page - 1}`, }); }; - - // 페이지 번호 배열 생성 (1부터 totalPages까지) + const handleWriteClick = () => navigate("/matching/write"); + const handleRowClick = (idx: number) => + navigate(`/matching/articles/${idx}`); const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); - // ──────────────────────────────────────────────────────────── + // 하이라이트 함수 + function highlightText(text: string, highlight: string): ReactNode { + if (!highlight) return text; + const regex = new RegExp(`(${highlight})`, "gi"); + return text.split(regex).map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ); + } + return (
- {/* 상단 필터 및 매칭 제안하기 버튼 */} -
-
- {/* 응원하는 팀 필터 (디자인만, 기능 제거) */} - - - {/* 티켓 보유 여부 필터 (디자인만) */} - - - {/* 검색어 입력 (디자인만) */} -
- +
+ {selectedGame.homeTeamName} + (e.currentTarget.src = "/images/default_team_emb.png") + } + /> + VS + {selectedGame.awayTeamName} + (e.currentTarget.src = "/images/default_team_emb.png") + } /> - - - -
+ {startTime} +
+ )} + + {/* ─── 필터 / 검색 / 제안 버튼 ─────────────────────────────── */} +
+ + +
+ setSearchTerm(e.target.value)} + /> + + + +
- {/* 매칭 제안하기 버튼 */}
- {/* 로딩 중 표시 */} + {/* ─── 목록 / 테이블 / 페이지네이션 ───────────────────────── */} {isLoading && (

로딩 중...

)} - - {/* 에러 메시지 표시 */} - {errorMsg && ( -

{errorMsg}

- )} - - {/* 데이터가 비어 있을 때 */} + {errorMsg &&

{errorMsg}

} {!isLoading && !errorMsg && listData.length === 0 && (

해당 게임에 대한 매칭 글이 없습니다.

)} - {/* 데이터가 있을 때 테이블 렌더링 */} {!isLoading && !errorMsg && listData.length > 0 && (
@@ -234,7 +259,7 @@ export default function MatchingListPage() { 작성자
- 매칭글 제목 + 제목 티켓 @@ -255,7 +280,7 @@ export default function MatchingListPage() { {item.authorNickname} - {item.title} + {highlightText(item.title, searchTerm)} {item.haveTicket ? "O" : "X"} @@ -267,14 +292,14 @@ export default function MatchingListPage() { )} - {/* 페이지네이션 UI */} {!isLoading && !errorMsg && totalPages > 1 && (
    - {/* 이전 버튼 */}
  • - {/* 페이지 번호 버튼들 */} {pageNumbers.map((page) => (
  • ))} - {/* 다음 버튼 */}
- {hasTicket && ( - - )} + ); } @@ -174,102 +170,64 @@ function GameSelector({ export default function MatchingWritePage() { const navigate = useNavigate(); - // ──────────────────────────────────────────────────────────── - // 폼 상태 const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [hasTicket, setHasTicket] = useState(null); - const [date, setDate] = useState(""); // "YYYY-MM-DD" - const [game, setGame] = useState(""); // 선택된 gameIdx(문자열) - - // 실제 백엔드에서 받아온 gameIdx 목록 + const [date, setDate] = useState(""); + const [game, setGame] = useState(""); const [gameOptions, setGameOptions] = useState<{ id: string; label: string }[]>([]); - // ──────────────────────────────────────────────────────────── - // 1) “날짜(date)”가 선택될 때마다 백엔드에서 해당 날짜의 모든 경기 목록을 가져오기 + // 날짜 선택 시 경기 목록 불러오기 useEffect(() => { if (!date) { - setGameOptions([]); // 날짜가 비어 있으면 옵션 초기화 + setGameOptions([]); return; } - const [yyyy, mm, dd] = date.split("-"); const monthParam = `${yyyy}-${mm}`; const dayParam = parseInt(dd, 10); - const fetchGames = async () => { + (async () => { try { - // cheeringTeamIdx 파라미터 없이 호출 → 해당 날짜에 열리는 모든 경기 반환 const resp = await axios.get("/home/calendar-games", { - params: { - month: monthParam, - day: dayParam - }, + params: { month: monthParam, day: dayParam }, }); - const todayObj = resp.data.days.find((d) => d.day === dayParam); - if (todayObj) { - const opts = todayObj.games.map((g) => ({ - id: String(g.gameIdx), - label: `${g.homeTeamName} vs ${g.awayTeamName} (${g.startTime})`, - })); - setGameOptions(opts); - } else { - setGameOptions([]); - } - } catch (e) { - console.error("달력 API 호출 실패:", e); + const today = resp.data.days.find((d) => d.day === dayParam); + setGameOptions( + today + ? today.games.map((g) => ({ + id: String(g.gameIdx), + label: `${g.homeTeamName} vs ${g.awayTeamName} (${g.startTime})`, + })) + : [] + ); + } catch { setGameOptions([]); } - }; - - fetchGames(); + })(); }, [date]); - // ──────────────────────────────────────────────────────────── - // 티켓 인증 (더미) const handleVerifyTicket = () => { alert("티켓 인증 요청(구현 필요)"); }; - // ──────────────────────────────────────────────────────────── - // 매칭 글 등록 제출 처리 const handleSubmit = async () => { - // 1) 유효성 검사 - if (!title.trim()) { - alert("제목을 입력해 주세요."); - return; - } - if (!content.trim()) { - alert("내용을 입력해 주세요."); - return; - } - if (hasTicket === null) { - alert("티켓 보유 여부를 선택해 주세요."); - return; - } - if (!date) { - alert("경기 날짜를 선택해 주세요."); - return; - } - if (!game) { - alert("경기를 선택해 주세요."); - return; - } + if (!title.trim()) return alert("제목을 입력해 주세요."); + if (!content.trim()) return alert("내용을 입력해 주세요."); + if (hasTicket === null) return alert("티켓 보유 여부를 선택해 주세요."); + if (!date) return alert("경기 날짜를 선택해 주세요."); + if (!game) return alert("경기를 선택해 주세요."); - // 2) 요청 바디 생성 (스펙에 맞춰 정확히 보내기) const body: CreateMatchingPostRequest = { title: title.trim(), context: content.trim(), haveTicket: hasTicket, - gameIdx: parseInt(game, 10), // 반드시 숫자 타입(정수)로 + gameIdx: parseInt(game, 10), }; try { - // (예시) 로컬 스토리지에 저장된 JWT 토큰 꺼내기 const token = localStorage.getItem("jwtToken") || ""; - - // 3) POST /matching-post API 호출 (Authorization 헤더 포함) - const response = await axios.post( + const res = await axios.post( "/matching-post", body, { @@ -279,41 +237,30 @@ export default function MatchingWritePage() { }, } ); - - // 4) 성공 시 반환된 postIdx로 상세 페이지로 이동 - const { postIdx } = response.data; alert("매칭 글이 성공적으로 등록되었습니다."); - navigate(`/matching/articles/${postIdx}`); - } catch (error) { - console.error("매칭 글 등록 실패:", error); - alert("매칭 글 등록 중 오류가 발생했습니다. 서버 로그를 확인해 주세요."); + navigate(`/matching/articles/${res.data.postIdx}`); + } catch (err) { + console.error(err); + alert("매칭 글 등록 중 오류가 발생했습니다."); } }; return ( -
- {/* 헤더 */} -
- -
+
+ {/* 뒤로가기 버튼 (뷰포트 기준 좌상단 고정) */} +
+ +
- {/* 폼 */} -
+ {/* 중앙 폼 컨테이너: space-y-6으로 섹션 간격 균일하게 */} +
- - diff --git a/src/pages/Profile/Profile.tsx b/src/pages/Profile/Profile.tsx index aac5417..523b001 100644 --- a/src/pages/Profile/Profile.tsx +++ b/src/pages/Profile/Profile.tsx @@ -1,10 +1,11 @@ // src/pages/Profile/Profile.tsx + import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { getTeamLogoByIdx, getTeamNameByIdx, -} from "../../hooks/TeamNameChanger"; // 실제 경로에 맞게 수정해주세요 +} from "../../hooks/TeamNameChanger"; interface ProfileData { userIdx: number; @@ -25,15 +26,13 @@ interface MyMatchingPostDto { context: string; haveTicket: boolean; createdAt: string; - isMatched: boolean; // 매칭 완료 여부 + 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); @@ -44,8 +43,8 @@ export default function Profile() { const token = localStorage.getItem("jwtToken"); if (!token) throw new Error("토큰이 없습니다."); - // 1) 프로필 정보 조회 - const profileRes = await fetch( + // 프로필 조회 + const pr = await fetch( `${import.meta.env.VITE_API_URL}/mypage/myTemp`, { method: "GET", @@ -56,16 +55,14 @@ export default function Profile() { }, } ); - if (!profileRes.ok) - throw new Error(`프로필 조회 실패: ${profileRes.status}`); - const profileData: ProfileData = await profileRes.json(); + if (!pr.ok) throw new Error(`프로필 조회 실패: ${pr.status}`); + const profileData: ProfileData = await pr.json(); setProfile(profileData); - // 2) 내가 쓴 매칭 글 목록 조회 (/mypage/myPost) + // 내 매칭 글 조회 setIsLoadingPosts(true); setPostsError(null); - - const postsRes = await fetch( + const ps = await fetch( `${import.meta.env.VITE_API_URL}/mypage/myPost`, { method: "GET", @@ -76,12 +73,11 @@ export default function Profile() { }, } ); - if (!postsRes.ok) - throw new Error(`내 글 조회 실패: ${postsRes.status}`); - const postsData: MyMatchingPostDto[] = await postsRes.json(); + if (!ps.ok) throw new Error(`내 글 조회 실패: ${ps.status}`); + const postsData: MyMatchingPostDto[] = await ps.json(); setMyPosts(postsData); } catch (err: any) { - console.error("프로필 또는 매칭 글 불러오기 실패:", err); + console.error(err); setPostsError("내가 쓴 매칭 글을 불러오는 중 오류가 발생했습니다."); } finally { setIsLoadingPosts(false); @@ -89,7 +85,6 @@ export default function Profile() { })(); }, []); - // 팀명/로고 파생 값 const cheeringTeamName = profile?.cheeringTeamId != null ? getTeamNameByIdx(profile.cheeringTeamId) @@ -99,34 +94,24 @@ export default function Profile() { ? 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", + Accept: "application/json", Authorization: `Bearer ${token}`, }, } ); - if (!res.ok) { - throw new Error(`매칭 토글 실패: ${res.status}`); - } - const updated = await res.json(); - // 예시 응답: { matchingPostIdx: 3, matched: true } - - // 로컬 상태에서도 해당 게시글의 isMatched 값을 반전시켜서 업데이트 + if (!res.ok) throw new Error(`매칭 토글 실패: ${res.status}`); + const updated = await res.json(); setMyPosts((prev) => prev.map((p) => p.matchingPostIdx === updated.matchingPostIdx @@ -135,196 +120,180 @@ export default function Profile() { ) ); } catch (error: any) { - console.error("매칭 토글 중 오류 발생:", error); + console.error(error); alert(`오류: ${error.message}`); } }; + // userTemp 그대로 %로 사용 (36.5℃ → 36.5%) + const fillPercent = + profile?.userTemp != null ? Math.min(profile.userTemp, 100) : 0; + 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 || "자기소개가 없습니다."} -

- {/* 수정 / 설정 버튼 */} -
- - -
+
+ {/* 헤더 배너 */} +
+
+
+
+ 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)}℃` - : "–"} -
-
-
+ 팀 엠블럼 { + 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) => ( + {/* 작성한 매칭 글 */} +
+

+ 작성한 매칭 글 +

+ {isLoadingPosts && ( +

불러오는 중...

+ )} + {postsError &&

{postsError}

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

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

+ )} + {!isLoadingPosts && !postsError && myPosts.length > 0 && ( +
+ {myPosts.map((post) => ( +
+ navigate(`/matching/articles/${post.matchingPostIdx}`) + } > -
- 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 ? "티켓 있음" : "티켓 없음"} - -
- +
+
+
+ + {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 → “다시 매칭하기” - */} -
- ))} -
- )} -
-
- + +
+ ))} +
+ )} + +
); }