diff --git a/.gitlab/issue_templates/merge_request_templates.md b/.gitlab/issue_templates/merge_request_templates.md new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/api/friend.js b/src/api/friend.js new file mode 100644 index 0000000000000000000000000000000000000000..7a5a7c70bd68d3c4d49d5a7a1ccb0ffd55a13598 --- /dev/null +++ b/src/api/friend.js @@ -0,0 +1,126 @@ +// 기본 API URL +const BASE_URL = process.env.REACT_APP_BASE_URL; + +/** + * 친구 요청 보내기 + * @param {Object} requestData - 요청 데이터 (userId, email) + * @returns {Promise<Object>} - 생성된 친구 요청 데이터 + */ +export const sendFriendRequest = async (requestData) => { + const response = await fetch(`${BASE_URL}/api/friend/request`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error("Failed to send friend request"); + } + + return await response.json(); +}; + +/** + * 받은 친구 요청 조회 + * @returns {Promise<Object[]>} - 받은 친구 요청 리스트 + */ +export const getReceivedFriendRequests = async () => { + const response = await fetch(`${BASE_URL}/api/friend/requests/received`); + + if (!response.ok) { + throw new Error("Failed to fetch received friend requests"); + } + + return (await response.json()).data; +}; + +/** + * 보낸 친구 요청 조회 + * @returns {Promise<Object[]>} - 보낸 친구 요청 리스트 + */ +export const getSentFriendRequests = async () => { + const response = await fetch(`${BASE_URL}/api/friend/requests/sent`); + + if (!response.ok) { + throw new Error("Failed to fetch sent friend requests"); + } + + return (await response.json()).data; +}; + +/** + * 친구 요청 수락 + * @param {number} requestId - 친구 요청 ID + * @returns {Promise<Object>} - 수락된 친구 요청 데이터 + */ +export const acceptFriendRequest = async (requestId) => { + const response = await fetch( + `${BASE_URL}/api/friend/request/${requestId}/accept`, + { + method: "POST", + } + ); + + if (!response.ok) { + throw new Error("Failed to accept friend request"); + } + + return (await response.json()).data; +}; + +/** + * 친구 요청 거절 + * @param {number} requestId - 친구 요청 ID + * @returns {Promise<Object>} - 거절된 친구 요청 데이터 + */ +export const rejectFriendRequest = async (requestId) => { + const response = await fetch( + `${BASE_URL}/api/friend/request/${requestId}/reject`, + { + method: "POST", + } + ); + + if (!response.ok) { + throw new Error("Failed to reject friend request"); + } + + return (await response.json()).data; +}; + +/** + * 친구 목록 조회 + * @param {number} page - 페이지 번호 + * @param {number} size - 페이지 크기 + * @returns {Promise<Object>} - 친구 목록 데이터 + */ +export const getAllFriends = async (page = 0, size = 10) => { + const response = await fetch( + `${BASE_URL}/api/friend/all?page=${page}&size=${size}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch friends list"); + } + + return (await response.json()).data.content; +}; + +/** + * 친구 삭제 + * @param {number} friendId - 삭제할 친구 ID + * @returns {Promise<Object>} - 삭제 결과 데이터 + */ +export const deleteFriend = async (friendId) => { + const response = await fetch(`${BASE_URL}/api/friends/${friendId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete friend"); + } + + return await response.json(); +}; diff --git a/src/api/meeting.js b/src/api/meeting.js new file mode 100644 index 0000000000000000000000000000000000000000..e4f7611a1deb2939900cb547142dc89e5404b3d4 --- /dev/null +++ b/src/api/meeting.js @@ -0,0 +1,35 @@ +// src/api/meeting.js + +// 기본 API URL +const BASE_URL = process.env.REACT_APP_BASE_URL; + +// 내가 참여하고 있는 채팅방 가져오기 +export const fetchMyMeetings = async (page = 0, size = 20) => { + try { + const response = await fetch( + `${BASE_URL}/api/meeting/my?page=${page}&size=${size}`, + { + method: "GET", + credentials: "include", // 세션 기반 인증을 위해 필요 + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error("Failed to fetch meetings."); + } + + return result.data; // 서버에서 제공된 데이터 반환 + } catch (error) { + console.error("Error fetching my meetings:", error); + throw error; + } +}; diff --git a/src/components/Card.jsx b/src/components/Card.jsx index 5dfcc0e5f7c67eb3d20fa0ed7f96672bd60fba98..b72175112aa916c5819a3f45f385e62cfd8b5728 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -1,8 +1,10 @@ import { cn } from "../libs/index"; import { cva } from "class-variance-authority"; import React from "react"; +import Label from "./Label"; -const cardVariants = cva("w-[20rem] rounded-xl shadow-lg p-4 overflow-hidden", { +// Card 스타일 변수를 정의합니다. +const cardVariants = cva("w-full rounded-xl shadow-lg p-4 overflow-hidden", { variants: { theme: { black: "bg-black text-white", @@ -11,35 +13,46 @@ const cardVariants = cva("w-[20rem] rounded-xl shadow-lg p-4 overflow-hidden", { purple: "bg-gradient-purple text-white", indigo: "bg-gradient-indigo text-white", mix: "bg-gradient-mix text-white", + gray: "bg-gray-300 text-gray-500", }, }, }); -export default function Card({ - image, - title, - content, - time, - headCount, - theme = "black", - onClick, -}) { - const variantClass = cardVariants({ theme }); +export default function Card({ meeting, theme = "black", onClick }) { + const { + title, + timeIdxStart, + timeIdxEnd, + location, + creatorName, + time_idx_deadline, + type, + } = meeting; + + const variantClass = cardVariants({ + theme: type === "CLOSE" ? "gray" : theme, + }); return ( <div className={cn(variantClass)} onClick={onClick}> - {image && ( - <img - src={image} - alt={title} - className="object-contain w-full h-48 rounded-xl" - /> - )} - <div className="p-3"> - <h3 className="mb-2 text-xl font-bold">{title}</h3> - <p className="text-base">{content}</p> - <p className="text-base">{time}</p> - <p className="mt-5 text-right">{headCount}</p> + <h3 className="mb-2 text-xl font-bold">{title}</h3> + <div className="flex gap-2 mb-2"> + <Label size="sm" theme="indigo"> + {location} + </Label> + <Label size="sm" theme="indigo"> + 시간: {timeIdxStart} ~ {timeIdxEnd} + </Label> + </div> + + <Label size="sm" theme="indigo"> + 마감 시간: {time_idx_deadline} + </Label> + <div className="flex justify-between mt-2"> + <span className="text-sm text-white">작성자: {creatorName}</span> + <span className="text-sm text-right"> + {type === "OPEN" ? "참여 가능" : "참여 마감"} + </span> </div> </div> ); diff --git a/src/components/Label.jsx b/src/components/Label.jsx index 6fae1290760194bf523407a5aeafc45c972b0a2a..8a2edf87a591f1cdb85e1655dc1bc3f24b7b2719 100644 --- a/src/components/Label.jsx +++ b/src/components/Label.jsx @@ -9,7 +9,7 @@ const labelVariants = cva( theme: { indigo: "bg-secondary-900 text-white", solid: "bg-primary-600 text-white", - lightsolid: "bg-primary-100 text-primary-600", + lightsolid: "bg-primary-600 text-primary-100 border border-primary-100", graysolid: "bg-grayscale-100 text-grayscale-900", ghost: "border border-grayscale-500 text-grayscale-900", }, diff --git a/src/components/layout/HeaderNav.jsx b/src/components/layout/HeaderNav.jsx index f274349764b8e5cc1ffccf2a1b779499cbf25eeb..426f712c988365c6cc28b9376fc4e45036fbe0d5 100644 --- a/src/components/layout/HeaderNav.jsx +++ b/src/components/layout/HeaderNav.jsx @@ -89,7 +89,7 @@ export default function HeaderNav() { icon={<ChatIcon />} onClick={navigateToChattingList} > - 번개채팅방 + 번개모임 </Button> {user ? ( <Button diff --git a/src/pages/Mypage.jsx b/src/pages/Mypage.jsx index 7ec50653c6045eedfcbf920af95114e5f69bc768..2c8c013d6a55e86e58ccc7e7b9b868a0496aeb79 100644 --- a/src/pages/Mypage.jsx +++ b/src/pages/Mypage.jsx @@ -1,7 +1,328 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; +import useAuthStore from "../store/authStore"; +import { + getReceivedFriendRequests, + sendFriendRequest, + getAllFriends, + acceptFriendRequest, + rejectFriendRequest, + deleteFriend, +} from "../api/friend"; +import Button from "../components/Button"; +import LogoIcon from "../components/icons/LogoIcon"; +import { fetchMyMeetings } from "../api/meeting"; +import Card from "../components/Card"; +import { useNavigate } from "react-router-dom"; const MyPage = () => { - return <></>; + const { user, fetchSession } = useAuthStore(); // Zustand 상태 및 메서드 가져오기 + const [activeTab, setActiveTab] = useState("lightning"); // 현재 활성화된 탭 + const [receivedRequests, setReceivedRequests] = useState([]); + const [sentRequests, setSentRequests] = useState([]); + const [friends, setFriends] = useState([]); + const [email, setEmail] = useState(""); // 친구 요청할 이메일 + const [page, setPage] = useState(0); // 친구 목록 페이지 + const [hasNext, setHasNext] = useState(true); // 페이지네이션 상태 + const [isLoading, setIsLoading] = useState(false); + + const [meetings, setMeetings] = useState([]); + const [meetingPage, setMeetingPage] = useState(0); + const [meetingHasNext, setMeetingHasNext] = useState(true); + const [meetingIsLoading, setMeetingIsLoading] = useState(false); + + const navigate = useNavigate(); + + // 탭 변경 함수 + const switchTab = (tab) => setActiveTab(tab); + + useEffect(() => { + const fetchUserSession = async () => { + try { + const userInfo = localStorage.getItem("user"); + if (!userInfo) { + alert("로그인이 필요한 페이지입니다."); + navigate("/login"); + } + await fetchSession(); // 세션 정보 가져오기 + } catch (error) { + console.error("Failed to fetch session:", error); + } + }; + + fetchUserSession(); + }, [fetchSession, navigate]); // 페이지 마운트 시 실행 + + // 번개 모임 가져오기 + useEffect(() => { + const fetchMeetings = async () => { + if (!meetingHasNext || meetingIsLoading) return; + + try { + setMeetingIsLoading(true); + const data = await fetchMyMeetings(meetingPage, 20); + setMeetings((prev) => [...prev, ...data.content]); + setMeetingHasNext(data.meetingHasNext); + setMeetingPage((prev) => prev + 1); + } catch (error) { + console.error("Failed to fetch meetings:", error); + } finally { + setIsLoading(false); + } + }; + + if (activeTab === "lightning") fetchMeetings(); + }, [activeTab, meetingPage, meetingHasNext, meetingIsLoading]); + + // 받은 친구 요청 조회 + useEffect(() => { + const fetchReceivedRequests = async () => { + try { + const data = await getReceivedFriendRequests(); + setReceivedRequests(data); + } catch (error) { + console.error("Failed to fetch received requests:", error); + } + }; + if (activeTab === "friends") fetchReceivedRequests(); + }, [activeTab]); + + // 친구 목록 무한스크롤 처리 + useEffect(() => { + const fetchFriends = async () => { + if (!hasNext || isLoading) return; + try { + setIsLoading(true); + const data = await getAllFriends(page, 10); + setFriends((prev) => [...prev, ...data.content]); + setHasNext(data.hasNext); + setPage((prev) => prev + 1); + } catch (error) { + console.error("Failed to fetch friends:", error); + } finally { + setIsLoading(false); + } + }; + if (activeTab === "friends") fetchFriends(); + }, [page, hasNext, activeTab, isLoading]); + + // 친구 요청 보내기 + const handleSendRequest = async () => { + try { + const requestData = { email }; + const response = await sendFriendRequest(requestData); + setSentRequests((prev) => [...prev, response.data]); + setEmail(""); // 입력 필드 초기화 + } catch (error) { + console.error("Failed to send friend request:", error); + } + }; + + // 친구 요청 수락 + const handleAcceptRequest = async (requestId) => { + try { + const response = await acceptFriendRequest(requestId); + setReceivedRequests((prev) => + prev.filter((request) => request.id !== requestId) + ); + setFriends((prev) => [response, ...prev]); // 친구 목록에 추가 + } catch (error) { + console.error("Failed to accept request:", error); + } + }; + + // 친구 요청 거절 + const handleRejectRequest = async (requestId) => { + try { + await rejectFriendRequest(requestId); + setReceivedRequests((prev) => + prev.filter((request) => request.id !== requestId) + ); + } catch (error) { + console.error("Failed to reject request:", error); + } + }; + + // 친구 삭제 + const handleDeleteFriend = async (friendId) => { + try { + await deleteFriend(friendId); + setFriends((prev) => prev.filter((friend) => friend.id !== friendId)); + } catch (error) { + console.error("Failed to delete friend:", error); + } + }; + + return ( + <div className="w-full max-w-screen-lg min-h-screen mx-auto bg-white"> + {/* 프로필 영역 */} + <div className="flex items-center px-6 py-4 border-b"> + <div className="flex-shrink-0"> + <LogoIcon className="w-20 h-20 rounded-full" /> + </div> + <div className="flex-grow ml-6"> + <h1 className="text-xl font-bold">{user?.name || "Guest"}</h1> + <p className="text-gray-600">{user?.email || "guest@example.com"}</p> + </div> + </div> + + {/* 탭 네비게이션 */} + <div className="flex justify-center py-2 space-x-6 border-b"> + <button + className={`px-4 py-2 text-sm font-medium ${ + activeTab === "lightning" + ? "text-secondary-500 border-b-2 border-secondary-500" + : "text-gray-600" + }`} + onClick={() => switchTab("lightning")} + > + 번개 모임 + </button> + <button + className={`px-4 py-2 text-sm font-medium ${ + activeTab === "friends" + ? "text-tertiary-500 border-b-2 border-tertiary-500" + : "text-gray-600" + }`} + onClick={() => switchTab("friends")} + > + 친구 + </button> + </div> + {/* 번개 모임 탭 */} + {activeTab === "lightning" && ( + <div className="p-4"> + {meetings.length === 0 && !isLoading && ( + <p className="text-center">참여 중인 번개 모임이 없습니다.</p> + )} + <div className="grid grid-cols-1 gap-4 tablet:grid-cols-2 desktop:grid-cols-3"> + {meetings.map((meeting) => ( + <Card + key={meeting.id} + meeting={meeting} + theme="purple" + onClick={() => console.log("Clicked meeting:", meeting.id)} + /> + ))} + </div> + {isLoading && <p className="text-center">로딩 중...</p>} + {!hasNext && meetings.length > 0 && ( + <p className="text-sm text-center text-gray-500"> + 더 이상 불러올 번개 모임이 없습니다. + </p> + )} + </div> + )} + + {/* 친구 탭 */} + {activeTab === "friends" && ( + <div className="p-4 space-y-8"> + {/* 친구 요청하기 */} + <div> + <h2 className="text-lg font-bold">친구 요청하기</h2> + <div className="flex items-center my-4 space-x-4"> + <input + type="email" + className="flex-1 min-w-0 p-3 border rounded-full" + placeholder="친구 이메일 입력" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + <Button + size="md" + theme="indigo" + className="min-w-[50px]" + onClick={handleSendRequest} + > + 요청 + </Button> + </div> + <div className="mt-4 space-y-4"> + {sentRequests.map((request) => ( + <div key={request.id} className="p-2 border rounded-lg"> + <p className="text-sm"> + {request.receiver.name} ({request.receiver.email}) + </p> + <p className="text-xs text-gray-500">{request.status}</p> + </div> + ))} + </div> + </div> + <hr /> + {/* 받은 친구 요청 */} + <div> + <h2 className="mb-2 text-lg font-bold">받은 친구 요청</h2> + <div className="space-y-4"> + {receivedRequests.map((request) => ( + <div + key={request.id} + className="flex items-center justify-between p-4 border rounded-lg" + > + <div> + <h3 className="font-semibold text-md"> + {request.requester.name} + </h3> + <p className="text-[10px] tablet:text-sm text-gray-600"> + {request.requester.email} + </p> + </div> + <div className="flex gap-2"> + <Button + size="sm" + theme="indigo" + onClick={() => handleAcceptRequest(request.id)} + > + 수락 + </Button> + <Button + size="sm" + theme="pink" + onClick={() => handleRejectRequest(request.id)} + > + 거절 + </Button> + </div> + </div> + ))} + </div> + </div> + <hr /> + + {/* 친구 목록 */} + <div> + <h2 className="mb-2 text-lg font-bold">친구 목록</h2> + <div className="space-y-4"> + {friends.map((friend) => ( + <div + key={friend.id} + className="flex items-center justify-between p-4 border rounded-lg" + > + <div> + <h3 className="font-semibold text-md"> + {friend.friendInfo.name} + </h3> + <p className="text-[10px] tablet:text-sm text-gray-600"> + {friend.friendInfo.email} + </p> + </div> + <Button + size="sm" + theme="black" + onClick={() => handleDeleteFriend(friend.id)} + > + 삭제 + </Button> + </div> + ))} + {isLoading && <p>로딩 중...</p>} + {!hasNext && ( + <p className="text-center label-2">더 이상 친구가 없습니다.</p> + )} + </div> + </div> + </div> + )} + </div> + ); }; export default MyPage; diff --git a/src/store/authStore.js b/src/store/authStore.js index 05da3b6d76f0c33cc904ff09ce58023eeacdb35f..5715ec0790157be2ec13b29f16ec913276eabfff 100644 --- a/src/store/authStore.js +++ b/src/store/authStore.js @@ -11,6 +11,7 @@ const useAuthStore = create((set) => ({ try { const userInfo = await getSessionInfo(); set({ user: userInfo }); + localStorage.setItem("user", { user: userInfo }); } catch (error) { console.error("Failed to fetch session info:", error); set({ user: null }); @@ -24,6 +25,7 @@ const useAuthStore = create((set) => ({ try { await logout(); set({ user: null }); + localStorage.removeItem("user"); } catch (error) { console.error("Failed to logout:", error); }