diff --git a/next.config.mjs b/next.config.mjs index 57eb6c1..8a661c9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -13,6 +13,11 @@ const nextConfig = { destination: '/meeting/mogakco', permanent: true, }, + { + source: '/my-meeting', + destination: '/my-meeting/my?type=created', + permanent: true, + }, ]; }, diff --git a/src/app/(user-page)/components/CardRightSection.tsx b/src/app/(user-page)/components/CardRightSection.tsx new file mode 100644 index 0000000..723e892 --- /dev/null +++ b/src/app/(user-page)/components/CardRightSection.tsx @@ -0,0 +1,175 @@ +'use client'; + +import Dropdown from '@/components/common/Dropdown'; +import { Button } from '@/components/ui/Button'; +import { Tag } from '@/components/ui/Tag'; +import Modal from '@/components/ui/modal/Modal'; +import { + useExpelMutation, + useMemberStatusMutation, +} from '@/hooks/mutations/useMyMeetingMutation'; +import Image from 'next/image'; +import { useState } from 'react'; +import type { Member } from 'types/myMeeting'; + +import ModalProfile from './ModalProfile'; +import ModalUserList from './ModalUserList'; + +const CardRightSection = ({ + memberList, + isPublic, + className, + meetingId, +}: { + memberList: Member[]; + isPublic: boolean; + className?: string; + meetingId: number; +}) => { + const [selectedFilter, setSelectedFilter] = useState( + isPublic ? '공개' : '비공개', + ); + const [isUserListModalOpen, setIsUserListModalOpen] = useState(false); + const handleConfirm = () => { + setIsUserListModalOpen(false); + }; + const filterAreaOptions = [ + { value: 'true', label: '공개' }, + { value: 'false', label: '비공개' }, + ]; + + const [isUserProfileModalOpen, setIsUserProfileModalOpen] = useState(false); + + // 가입 승인 / 거절 + const { mutate: statusMutate } = useMemberStatusMutation(meetingId); + + // 내보내기 + const { mutate: expelMutate } = useExpelMutation(meetingId); + + const handleSecondModalConfirm = () => { + // 가입 확인 api 연동 + // 만약, status가 approved라면 -> 내보내기 활성화 + // 만약, status가 pending이 아니라면 -> 닫기만 활성화 + + if (selectedUser && selectedUser.memberStatus === 'PENDING') { + statusMutate({ + setMemberStatus: 'APPROVED', + userId: selectedUser?.userId, + }); + } + + setIsUserProfileModalOpen(false); + }; + + const handleSecondModalCancel = () => { + // 가입 거절 api 연동 + if (selectedUser && selectedUser.memberStatus === 'PENDING') { + statusMutate({ + setMemberStatus: 'REJECTED', + userId: selectedUser?.userId, + }); + } else if (selectedUser && selectedUser.memberStatus === 'APPROVED') { + expelMutate({ + setMemberStatus: 'EXPEL', + userId: selectedUser?.userId, + }); + } + + setIsUserProfileModalOpen(false); + }; + + // 프로필 보기 할 유저 + const [selectedUser, setSelectedUser] = useState(null); + + return ( +
+
+

참가 중인 멤버

+
+ {memberList.map((member: Member) => ( +
+ 맴버 프로필 +

+ {member.name} +

+
+ + +
+
+ ))} +
+
+ +
+ +
+ + + + setIsUserListModalOpen(false)} + onConfirm={handleConfirm} + showOnly + modalClassName="h-[590px] w-[520px] overflow-y-auto" + > + + +
+ ); +}; + +export default CardRightSection; diff --git a/src/app/(user-page)/components/Created.tsx b/src/app/(user-page)/components/Created.tsx new file mode 100644 index 0000000..5e80cf4 --- /dev/null +++ b/src/app/(user-page)/components/Created.tsx @@ -0,0 +1,155 @@ +'use client'; + +import HorizonCard from '@/components/ui/HorizonCard'; +import useInfiniteScroll from '@/hooks/common/useInfiniteScroll'; +import { useInfiniteMyMeetingManageQueries } from '@/hooks/queries/useMyMeetingQueries'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import CardRightSection from './CardRightSection'; + +const Created = () => { + const router = useRouter(); + + const { + data: meetingData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + error, + } = useInfiniteMyMeetingManageQueries(); + + const lastMeetingRef = useInfiniteScroll({ + fetchNextPage, + isFetchingNextPage, + hasNextPage, + }); + + if (isLoading || !meetingData) { + return

loading...

; + } + + const handleMoveDetailPage = (meetingId: number) => { + /** + * TODO + * 추후 category 수정 + */ + router.push(`/meeting/study/${meetingId}`); + }; + + return ( +
+ {meetingData.pages.map((page, pageIdx) => ( +
+ {page.content.map((meeting) => { + { + console.log( + 'page.nextCursor: ', + page.nextCursor, + 'meeting.meetingId: ', + meeting.meetingId, + ); + } + return ( +
+ {/* 데스크탑 */} +
+ + + +
+ + {/* 태블릿 */} +
+ + +
+ + {/* 모바일 */} +
+ + +
+
+ ); + })} +
+ ))} +
+ ); +}; +export default Created; diff --git a/src/app/(user-page)/components/Joined.tsx b/src/app/(user-page)/components/Joined.tsx new file mode 100644 index 0000000..dad6ab0 --- /dev/null +++ b/src/app/(user-page)/components/Joined.tsx @@ -0,0 +1,191 @@ +// 'use client'; + +// import Dropdown from '@/components/common/Dropdown'; +// import { Button } from '@/components/ui/Button'; +// import HorizonCard from '@/components/ui/HorizonCard'; +// import { Tag } from '@/components/ui/Tag'; +// import Modal from '@/components/ui/modal/Modal'; +// import Image from 'next/image'; +// import { useState } from 'react'; + +// import { Meeting, Member } from '../my-meeting/my/page'; +// import ModalProfile from './ModalProfile'; +// import ModalUserList from './ModalUserList'; + +// const CardRightSection = ({ +// memberList, +// isPublic, +// className, +// }: { +// memberList: Member[]; +// isPublic: boolean; +// className?: string; +// }) => { +// const [selectedFilter, setSelectedFilter] = useState( +// isPublic ? '공개' : '비공개', +// ); +// const [isUserListModalOpen, setIsUserListModalOpen] = useState(false); +// const handleConfirm = () => { +// setIsUserListModalOpen(false); +// }; +// const filterAreaOptions = [ +// { value: 'true', label: '공개' }, +// { value: 'false', label: '비공개' }, +// ]; + +// const [isUserProfileModalOpen, setIsUserProfileModalOpen] = useState(false); + +// const handleSecondModalConfirm = () => { +// // 가입 확인 api 연동 +// setIsUserProfileModalOpen(false); +// }; + +// const handleSecondModalCancel = () => { +// // 가입 거절 api 연동 +// setIsUserProfileModalOpen(false); +// }; +// return ( +//
+//
+//

참가 중인 멤버

+//
+// {memberList.map((member: Member) => ( +//
+// 맴버 프로필 +//

+// {member.name} +//

+//
+// +// +//
+//
+// ))} +//
+//
+// +//
+// +//
+// +// +// +// setIsUserListModalOpen(false)} +// onConfirm={handleConfirm} +// showOnly +// modalClassName="h-[590px] w-[520px] overflow-y-auto" +// > +// +// +//
+// ); +// }; + +// const Joined = ({ meetings }: { meetings: Meeting[] }) => { +// return ( +//
+// {meetings.map((meeting) => { +// return ( +//
+// {/* 데스크탑 */} +//
+// +// +// +//
+ +// {/* 태블릿 */} +//
+// +// +//
+ +// {/* 모바일 */} +//
+// +// +//
+//
+// ); +// })} +//
+// ); +// }; +// export default Joined; diff --git a/src/app/(user-page)/components/MeetingTypeTab.tsx b/src/app/(user-page)/components/MeetingTypeTab.tsx new file mode 100644 index 0000000..3bf8102 --- /dev/null +++ b/src/app/(user-page)/components/MeetingTypeTab.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { usePathname, useRouter } from 'next/navigation'; + +const tabBar = [ + { label: '나의 모임', value: 'my', href: '/my-meeting/my?type=created' }, + { label: '찜한 모임', value: 'likes', href: '/my-meeting/likes' }, + { label: '나의 리뷰', value: 'comments', href: '/my-meeting/comments' }, +]; + +const MeetingTypeTab = () => { + const router = useRouter(); + const pathname = usePathname(); + const segments = pathname.split('/'); + const tab = segments[2] || 'my'; + + const activeStyle = 'border-b-2 border-main text-main'; + + return ( +
+ {tabBar.map((item) => ( + + ))} +
+ ); +}; + +export default MeetingTypeTab; diff --git a/src/app/(user-page)/components/ModalProfile.tsx b/src/app/(user-page)/components/ModalProfile.tsx new file mode 100644 index 0000000..31d5d6e --- /dev/null +++ b/src/app/(user-page)/components/ModalProfile.tsx @@ -0,0 +1,83 @@ +import Description from '@/components/common/Description'; +import { useMyMeetingMemberProfileQuries } from '@/hooks/queries/useMyMeetingQueries'; +import Image from 'next/image'; +import React from 'react'; + +const ModalProfile = ({ + userId, + meetingId, +}: { + userId: number | undefined; + meetingId: number; +}) => { + const { + data: user, + isLoading, + error, + } = useMyMeetingMemberProfileQuries({ + meetingId, + userId: userId!, + }); + + if (isLoading || !user) { + return
로딩중
; + } + + return ( +
+
+ 유저 프로필 +
+

{user.name}

+

{user.intro}

+
+
+
+
+ + + + +
+
+ + +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+
+
+
+ + + +
+
+ +
+
+
+ ); +}; + +export default ModalProfile; diff --git a/src/app/(user-page)/components/ModalUserList.tsx b/src/app/(user-page)/components/ModalUserList.tsx new file mode 100644 index 0000000..8d9bfaa --- /dev/null +++ b/src/app/(user-page)/components/ModalUserList.tsx @@ -0,0 +1,67 @@ +import { Tag } from '@/components/ui/Tag'; +import { useBannerQueries } from '@/hooks/queries/useMyPageQueries'; +import Image from 'next/image'; +import React from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; +import type { Member } from 'types/myMeeting'; + +import { Button } from '../../../components/ui/Button'; + +const ModalUserList = ({ + memberList, + setIsUserProfileModalOpen, + setIsUserListModalOpen, + setSelectedUser, +}: { + memberList: Member[]; + setSelectedUser: Dispatch>; + setIsUserProfileModalOpen: Dispatch>; + setIsUserListModalOpen: Dispatch>; +}) => { + const { data: currentUser, isLoading, error } = useBannerQueries(); + + const handleProfileClick = (user: Member) => { + setSelectedUser(user); + setIsUserProfileModalOpen(true); + setIsUserListModalOpen(false); + }; + + return ( +
+

맴버 리스트

+ {memberList.map((user) => ( +
+
+ 유저 프로필 +

{user.name}

+
+ {user.userId !== currentUser?.userId && ( +
+ +
+ +
+
+ )} +
+ ))} +
+ ); +}; + +export default ModalUserList; diff --git a/src/app/(user-page)/components/Tab.tsx b/src/app/(user-page)/components/Tab.tsx new file mode 100644 index 0000000..9dcf139 --- /dev/null +++ b/src/app/(user-page)/components/Tab.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { useRouter } from 'next/navigation'; + +const tabList = [ + { label: '내가 만든 모임', value: 'created' }, + { label: '내가 참여하고 있는 모임', value: 'joined' }, +]; + +const Tab = ({ type }: { type: string }) => { + const router = useRouter(); + + return ( +
+ {tabList.map((item) => ( + + ))} +
+ ); +}; +export default Tab; diff --git a/src/app/(user-page)/components/UserItem.tsx b/src/app/(user-page)/components/UserItem.tsx new file mode 100644 index 0000000..ada3042 --- /dev/null +++ b/src/app/(user-page)/components/UserItem.tsx @@ -0,0 +1,40 @@ +import { Button } from '@/components/ui/Button'; +import { Tag } from '@/components/ui/Tag'; +import Image from 'next/image'; +import type { UserData } from 'types/myMeeting'; + +const UserItem = ({ + user, + handleProfileClick, +}: { + user: UserData; + handleProfileClick: (user: UserData) => void; +}) => { + return ( +
+
+ 유저 프로필 +

{user.name}

+
+
+ +
+ +
+
+
+ ); +}; +export default UserItem; diff --git a/src/app/(user-page)/my-meeting/comments/page.tsx b/src/app/(user-page)/my-meeting/comments/page.tsx new file mode 100644 index 0000000..27b8b8c --- /dev/null +++ b/src/app/(user-page)/my-meeting/comments/page.tsx @@ -0,0 +1,5 @@ +import NotYet from '@/components/common/NotYet'; + +export default function CommentsPage() { + return ; +} diff --git a/src/app/(user-page)/my-meeting/layout.tsx b/src/app/(user-page)/my-meeting/layout.tsx new file mode 100644 index 0000000..9540c03 --- /dev/null +++ b/src/app/(user-page)/my-meeting/layout.tsx @@ -0,0 +1,14 @@ +import MeetingTypeTab from '../components/MeetingTypeTab'; + +export default function layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/(user-page)/my-meeting/likes/page.tsx b/src/app/(user-page)/my-meeting/likes/page.tsx new file mode 100644 index 0000000..3ae9476 --- /dev/null +++ b/src/app/(user-page)/my-meeting/likes/page.tsx @@ -0,0 +1,5 @@ +import NotYet from '@/components/common/NotYet'; + +export default function LikesPage() { + return ; +} diff --git a/src/app/(user-page)/my-meeting/my/page.tsx b/src/app/(user-page)/my-meeting/my/page.tsx new file mode 100644 index 0000000..6770542 --- /dev/null +++ b/src/app/(user-page)/my-meeting/my/page.tsx @@ -0,0 +1,22 @@ +import NotYet from '@/components/common/NotYet'; + +import Created from '../../components/Created'; +// import Joined from '../../components/Joined'; +import Tab from '../../components/Tab'; + +export default function Page({ + searchParams, +}: { + searchParams: { type: string }; +}) { + const type = searchParams?.type; + + return ( +
+
+ +
+ {type === 'created' ? : } +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9626d14..23acfcd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,7 +11,7 @@ export const metadata: Metadata = { title: 'DEVING', description: '개발자들만의 다양한 모임을 즐겨요!', icons: { - icon: '/logo.svg', // 또는 "/icon.png" + icon: '/logo.svg', }, }; @@ -36,7 +36,6 @@ export default async function RootLayout({ children: React.ReactNode; }>) { const userInfo = await getUserInfo(); - console.log('banner Info', userInfo); return ( diff --git a/src/app/login/components/DummyUser.tsx b/src/app/login/components/DummyUser.tsx index 4ea16be..b9d85e1 100644 --- a/src/app/login/components/DummyUser.tsx +++ b/src/app/login/components/DummyUser.tsx @@ -28,7 +28,7 @@ const DummyUser = () => { onClick={() => mutate({ email: 'a1056719@gmail.com', - password, + password: 'qwerqwer', }) } > diff --git a/src/app/preview/modal/Some.tsx b/src/app/preview/modal/Some.tsx index fe9cbc7..9f9942d 100644 --- a/src/app/preview/modal/Some.tsx +++ b/src/app/preview/modal/Some.tsx @@ -1,81 +1,129 @@ +import Description from '@/components/common/Description'; +import { Tag } from '@/components/ui/Tag'; +import Image from 'next/image'; +import { mock } from 'node:test'; import { useState } from 'react'; import React from 'react'; import { Button } from '../../../components/ui/Button'; import Modal from '../../../components/ui/modal/Modal'; -interface UserData { +export interface UserData { id: number; name: string; - status: '대기' | '승인' | '거절' | '강퇴'; + status: 'APPROVED' | 'REJECTED' | 'PENDING' | 'EXPEL'; introduction: string; + profilePic: string; } +export const mockUser = { + userId: 9, + name: '강윤지', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', + intro: '안녕하세요, 개발자 강윤지입니다. 안녕하세요, 개발자 강윤지입니다.', + email: 'yunji@naver.com', + position: 'Frontend', + skillArray: [], + gender: '비공개', + age: '선택 안함', + location: '선택 안함', + contactResponse: { + phone: null, + github: null, + kakao: null, + }, + memberResponse: { + memberId: 37, + message: '모임 주최자 입니다', + }, +}; + const mockUsers: UserData[] = [ { id: 1, name: '김민수', - status: '승인', + status: 'APPROVED', introduction: '안녕하세요! 웹 개발자 김민수입니다. React와 TypeScript를 주로 사용합니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 2, name: '이지원', - status: '대기', + status: 'PENDING', introduction: '백엔드 개발자 이지원입니다. Spring과 Node.js를 다룹니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 3, name: '박서연', - status: '거절', + status: 'REJECTED', introduction: 'UI/UX 디자이너 박서연입니다. 사용자 경험 개선에 관심이 많습니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 4, name: '최준호', - status: '승인', + status: 'APPROVED', introduction: '모바일 앱 개발자 최준호입니다. Flutter와 React Native를 사용합니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 5, name: '정다은', - status: '강퇴', + status: 'EXPEL', introduction: '프론트엔드 개발자 정다은입니다. Vue.js와 React를 주로 사용합니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 6, name: '강현우', - status: '승인', + status: 'APPROVED', introduction: '풀스택 개발자 강현우입니다. MERN 스택을 주로 사용합니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 7, name: '손미나', - status: '대기', + status: 'PENDING', introduction: 'DevOps 엔지니어 손미나입니다. AWS와 Docker를 다룹니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 8, name: '윤태호', - status: '거절', + status: 'REJECTED', introduction: '게임 개발자 윤태호입니다. Unity와 C#을 주로 사용합니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 9, name: '임수진', - status: '승인', + status: 'APPROVED', introduction: '데이터 엔지니어 임수진입니다. Python과 SQL을 다룹니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, { id: 10, name: '한도윤', - status: '대기', + status: 'PENDING', introduction: '보안 엔지니어 한도윤입니다. 네트워크 보안에 관심이 많습니다.', + profilePic: + 'https://deving-bucket.s3.ap-northeast-2.amazonaws.com/profile_img.png', }, ]; @@ -84,70 +132,122 @@ const Some = () => { const [selectedUser, setSelectedUser] = useState(null); const handleSecondModalConfirm = () => { + // 가입 확인 api 연동 setIsSecondModalOpen(false); }; - const getStatusColor = (status: string) => { - switch (status) { - case '승인': - return 'text-green-600'; - case '대기': - return 'text-yellow-600'; - case '거절': - return 'text-red-600'; - case '강퇴': - return 'text-gray-600'; - default: - return ''; - } + const handleSecondModalCancel = () => { + // 가입 거절 api 연동 + setIsSecondModalOpen(false); }; - const handleProfileClick = (user: UserData) => { setSelectedUser(user); setIsSecondModalOpen(true); }; return ( -
-
-
이름
-
상태
-
프로필
-
-
+
+

맴버 리스트

{mockUsers.map((user) => (
-
{user.name}
-
{user.status}
-
- +
+ 유저 프로필 +

{user.name}

+
+
+ +
+ +
))} setIsSecondModalOpen(false)} + onClose={handleSecondModalCancel} onConfirm={handleSecondModalConfirm} - confirmText="확인" - cancelText="닫기" - modalClassName="w-96" + confirmText="가입승인" + cancelText="가입거절" + modalClassName="w-[450px] overflow-hidden bg-BG_2" > - {selectedUser && ( + {mockUser && (
-

- {selectedUser.name}님의 프로필 -

-

{selectedUser.introduction}

+
+ 유저 프로필 +
+

{mockUser.name}

+

{mockUser.intro}

+
+
+
+
+ + + + +
+
+ + +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+ 자바스크립트 +
+
+
+
+
+ + + +
+
+ +
+
)}
diff --git a/src/components/common/Description.tsx b/src/components/common/Description.tsx new file mode 100644 index 0000000..e5a026a --- /dev/null +++ b/src/components/common/Description.tsx @@ -0,0 +1,22 @@ +const Description = ({ + label, + value, + children, +}: { + label: string; + value?: string | null; + children?: React.ReactNode; +}) => { + return ( +
+

{label}

+
+

+ {value} +

+ {children} +
+
+ ); +}; +export default Description; diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index bdd116c..d184123 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -48,7 +48,7 @@ const AfterLogin = ({ userInfo }: { userInfo: IUserInfo }) => { { value: 'mymeeting', label: '내 모임', - onSelect: () => router.push('/my-meeting'), + onSelect: () => router.push('/my-meeting/my?type=created'), }, { value: 'mypage', @@ -115,12 +115,16 @@ const MobileBeforeLogin = () => { }; const MobileAfterLogin = ({ userInfo }: { userInfo: IUserInfo }) => { + const { showToast } = useToast(); return (
diff --git a/src/components/common/NotYet.tsx b/src/components/common/NotYet.tsx new file mode 100644 index 0000000..7a3187c --- /dev/null +++ b/src/components/common/NotYet.tsx @@ -0,0 +1,22 @@ +import Image from 'next/image'; + +export default function NotYet() { + return ( +
+ 404_image +

준비 중인 페이지입니다.

+
+ 아직 준비 중인 페이지입니다. 조금만 기다려주세요. +
+ + 홈으로 돌아가기 + +
+ ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index ec2a730..873f8cf 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -15,8 +15,8 @@ const buttonVariants = cva( 'disabled:bg-disable disabled:text-disable_text', ], default: [ - 'bg-default', - 'text-main', + 'bg-disable', + 'text-Cgray500', 'hover:opacity-90', 'disabled:bg-disable disabled:text-disable_text', ], diff --git a/src/components/ui/modal/Modal.tsx b/src/components/ui/modal/Modal.tsx index 190d515..d9aaaf7 100644 --- a/src/components/ui/modal/Modal.tsx +++ b/src/components/ui/modal/Modal.tsx @@ -12,6 +12,7 @@ interface AlertModalProps { contentClassName?: string; buttonClassName?: string; closeOnly?: boolean; + showOnly?: boolean; } /** @@ -49,6 +50,7 @@ interface AlertModalProps { * @param props.onClose - 모달이 닫힐 때 호출되는 콜백 함수 * @param props.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 함수 (closeOnly가 false일 때만 필요) * @param props.closeOnly - true일 경우 닫기 버튼만 표시 (기본값: false) + * @param props.showOnly - true일 경우 버튼 표시 안함 (기본값: false) * @param props.confirmText - 확인 버튼의 텍스트 (기본값: '확인', closeOnly가 false일 때만 사용) * @param props.cancelText - 취소/닫기 버튼의 텍스트 (기본값: closeOnly가 true일 때 '닫기', false일 때 '취소') * @param props.children - 모달 내부에 표시될 컨텐츠 @@ -70,6 +72,7 @@ const Modal: React.FC = ({ contentClassName = '', buttonClassName = '', closeOnly = false, + showOnly = false, }) => { const handleBackdropClick = (e: React.MouseEvent): void => { if (e.target === e.currentTarget) { @@ -101,9 +104,11 @@ const Modal: React.FC = ({ >
{children}
-
+
{closeOnly ? ( - ) : ( diff --git a/src/hooks/mutations/useCommentMutation.ts b/src/hooks/mutations/useCommentMutation.ts index cddb583..d02d637 100644 --- a/src/hooks/mutations/useCommentMutation.ts +++ b/src/hooks/mutations/useCommentMutation.ts @@ -28,4 +28,3 @@ const useCommentMutation = (meetingId: number) => { }; export { useCommentMutation }; -// 생각해 봤는데 우리 너무 가끔 만나는 것 같아서 서운할 때도 있어요 앞으로 좀 더 자주만나요 diff --git a/src/hooks/mutations/useMyMeetingMutation.ts b/src/hooks/mutations/useMyMeetingMutation.ts new file mode 100644 index 0000000..7e262ac --- /dev/null +++ b/src/hooks/mutations/useMyMeetingMutation.ts @@ -0,0 +1,72 @@ +import { useToast } from '@/components/common/ToastContext'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { putExpel, putMemberStatus } from 'service/api/mymeeting'; + +import { myMeetingKeys } from '../queries/useMyMeetingQueries'; + +const useMemberStatusMutation = (meetingId: number) => { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + setMemberStatus, + userId, + }: { + setMemberStatus: 'APPROVED' | 'REJECTED'; + userId: number; + }) => putMemberStatus({ meetingId, userId, setMemberStatus }), + onSuccess: (_, variables) => { + if (variables.setMemberStatus === 'APPROVED') { + showToast('해당 유저의 가입을 승낙했어요!', 'success'); + } + if (variables.setMemberStatus === 'REJECTED') { + showToast('해당 유저의 가입을 거절했어요!', 'error'); + } + queryClient.invalidateQueries({ + queryKey: myMeetingKeys.manage(), + }); + queryClient.invalidateQueries({ + queryKey: myMeetingKeys.memberProfile(meetingId, variables.userId), + }); + }, + onError: (error: AxiosError) => { + if (error.response?.status) { + showToast('다시 시도해주세요.', 'error'); + } + }, + }); +}; + +// 내보내기 +const useExpelMutation = (meetingId: number) => { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + setMemberStatus, + userId, + }: { + setMemberStatus: 'EXPEL'; + userId: number; + }) => putExpel({ meetingId, userId, setMemberStatus }), + onSuccess: (_, variables) => { + showToast('해당 유저를 내보냈어요!', 'success'); + queryClient.invalidateQueries({ + queryKey: myMeetingKeys.manage(), + }); + queryClient.invalidateQueries({ + queryKey: myMeetingKeys.memberProfile(meetingId, variables.userId), + }); + }, + onError: (error: AxiosError) => { + if (error.response?.status) { + showToast('다시 시도해주세요.', 'error'); + } + }, + }); +}; + +export { useMemberStatusMutation, useExpelMutation }; diff --git a/src/hooks/queries/useMyMeetingQueries.ts b/src/hooks/queries/useMyMeetingQueries.ts new file mode 100644 index 0000000..084975f --- /dev/null +++ b/src/hooks/queries/useMyMeetingQueries.ts @@ -0,0 +1,44 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + getMyMeetingManage, + getMyMeetingMemberProfile, +} from 'service/api/mymeeting'; + +export const myMeetingKeys = { + all: ['mymeeting'] as const, + manage: () => [...myMeetingKeys.all, 'manage'] as const, + memberProfile: (meetingId: number, userId: number) => [ + ...myMeetingKeys.all, + 'profile', + { meetingId, userId }, + ], +}; + +export const useInfiniteMyMeetingManageQueries = () => { + return useInfiniteQuery({ + queryKey: myMeetingKeys.manage(), + queryFn: ({ pageParam }) => getMyMeetingManage(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => { + console.log('[mutation] lastPage: ', lastPage); + return lastPage.nextCursor ?? null; + }, + }); +}; + +// 특정 유저의 프로필 요청 +export const useMyMeetingMemberProfileQuries = ({ + meetingId, + userId, +}: { + meetingId: number; + userId: number; +}) => { + const { data, error, isLoading } = useQuery({ + queryKey: myMeetingKeys.memberProfile(meetingId, userId), + queryFn: () => getMyMeetingMemberProfile({ meetingId, userId }), + enabled: userId !== undefined, + }); + + return { data, error, isLoading }; +}; diff --git a/src/hooks/queries/useMyPageQueries.ts b/src/hooks/queries/useMyPageQueries.ts index d329702..2b34472 100644 --- a/src/hooks/queries/useMyPageQueries.ts +++ b/src/hooks/queries/useMyPageQueries.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + getBanner, getProfile, updateContactInfo, updatePassword, @@ -20,6 +21,7 @@ export const QUERY_KEYS = { profile: () => [...QUERY_KEYS.all, 'profile'] as const, skills: () => [...QUERY_KEYS.all, 'skills'] as const, contact: () => [...QUERY_KEYS.all, 'contact'] as const, + banner: () => [...QUERY_KEYS.all, 'banner'] as const, }; // 프로필 정보 조회 커스텀 훅 @@ -87,3 +89,13 @@ export const useUpdateSkillsMutation = () => { }, }); }; + +// banner 정보 불러오기 +export const useBannerQueries = () => { + const { data, error, isLoading } = useQuery({ + queryKey: QUERY_KEYS.banner(), + queryFn: () => getBanner(), + }); + + return { data, error, isLoading }; +}; diff --git a/src/hooks/useCard.tsx b/src/hooks/useCard.tsx index 723e7a1..bf505f7 100644 --- a/src/hooks/useCard.tsx +++ b/src/hooks/useCard.tsx @@ -121,11 +121,11 @@ const useCard = (meeting: MeetingDetail) => { setMent(''); break; case 'registerWait': - router.push('/mypage'); + router.push('/my-meeting/my?type=joined'); setIsModalOpen(false); break; case 'registerComplete': - router.push('/mypage'); + router.push('/my-meeting/my?type=joined'); setIsModalOpen(false); break; default: diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..801e696 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,21 @@ +import { getAccessToken } from '@/lib/serverActions'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + // 인증 토큰 확인 + const token = await getAccessToken(); + + // 토큰이 없으면 로그인 페이지로 리다이렉트 + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + // 인증된 사용자는 요청을 계속 진행 + return NextResponse.next(); +} + +// 미들웨어가 적용될 경로를 지정 +export const config = { + matcher: ['/mypage', '/my-meeting/:path*'], +}; diff --git a/src/service/api/meeting.ts b/src/service/api/meeting.ts index ef775ae..34cff72 100644 --- a/src/service/api/meeting.ts +++ b/src/service/api/meeting.ts @@ -4,7 +4,8 @@ import { getAccessToken } from '@/lib/serverActions'; import type { CategoryTitle, IMeetingSearchCondition, - PaginatedSearchMeeting, + Paginated, + SearchMeeting, TopMeeting, } from 'types/meeting'; @@ -24,7 +25,7 @@ const getMeetings = async ( pageParams: number, category: CategoryTitle, searchQueryObj: IMeetingSearchCondition, -): Promise => { +): Promise> => { const newSearchQueryObj = { ...searchQueryObj, lastMeetingId: pageParams }; const token = await getAccessToken(); diff --git a/src/service/api/mymeeting.ts b/src/service/api/mymeeting.ts new file mode 100644 index 0000000..9fc608e --- /dev/null +++ b/src/service/api/mymeeting.ts @@ -0,0 +1,73 @@ +import { authAPI } from '@/lib/axios/authApi'; +import { Paginated } from 'types/meeting'; +import type { IMemberProfile, IMyMeetingManage } from 'types/myMeeting'; + +// 내가 만든 모임 불러오기 +const getMyMeetingManage = async ( + lastMeetingId: number, +): Promise> => { + const res = await authAPI.get( + `/api/v1/mymeetings/manage?lastMeetingId=${lastMeetingId}&size=${6}`, + ); + + return res.data.data; +}; + +// 맴버 프로필 불러오기 +const getMyMeetingMemberProfile = async ({ + userId, + meetingId, +}: { + userId?: number; + meetingId: number; +}): Promise => { + const res = await authAPI.get( + `/api/v1/mymeetings/member-profile?userId=${userId}&meetingId=${meetingId}`, + ); + + return res.data.data; +}; + +// 가입 승인 / 거절 +const putMemberStatus = async ({ + userId, + meetingId, + setMemberStatus, +}: { + userId: number; + meetingId: number; + setMemberStatus: 'APPROVED' | 'REJECTED'; +}) => { + const res = await authAPI.put(`/api/v1/mymeetings/member-status`, { + userId, + meetingId, + setMemberStatus, + }); + + return res.data.data; +}; + +// 강퇴 +const putExpel = async ({ + userId, + meetingId, + setMemberStatus, +}: { + userId: number; + meetingId: number; + setMemberStatus: 'EXPEL'; +}) => { + const res = await authAPI.put(`/api/v1/mymeetings/expel`, { + userId, + meetingId, + setMemberStatus, + }); + + return res.data.data; +}; +export { + getMyMeetingManage, + getMyMeetingMemberProfile, + putMemberStatus, + putExpel, +}; diff --git a/src/service/api/mypageProfile.ts b/src/service/api/mypageProfile.ts index ad677dd..5e46d03 100644 --- a/src/service/api/mypageProfile.ts +++ b/src/service/api/mypageProfile.ts @@ -1,5 +1,6 @@ import { authAPI } from '@/lib/axios/authApi'; import { fileToBase64 } from '@/util/imageBase64'; +import { IBanner } from 'types/myMeeting'; import { IContactInfoUpdateRequest, @@ -70,6 +71,7 @@ export const updateProfileImage = async ( throw error; } }; + // 비밀번호 업데이트 API 함수 export const updatePassword = async ( passwordData: IPasswordUpdateRequest, @@ -81,6 +83,7 @@ export const updatePassword = async ( throw error; } }; + // 기술 스택 업데이트 API 함수 export const updateSkills = async ( skillArray: string[], @@ -94,3 +97,9 @@ export const updateSkills = async ( throw error; } }; + +// 배너 정보 불러오기 +export const getBanner = async (): Promise => { + const res = await authAPI.get('/api/v1/mypage/banner'); + return res.data.data; +}; diff --git a/src/types/meeting.ts b/src/types/meeting.ts index 7630c09..57efc5f 100644 --- a/src/types/meeting.ts +++ b/src/types/meeting.ts @@ -48,12 +48,11 @@ interface SearchMeeting { isLike: boolean; likesCount: number; } - -interface PaginatedSearchMeeting { +interface Paginated { pageable: Pageable; nextCursor: number; size: number; - content: SearchMeeting[]; + content: T[]; number: number; sort: Sort; numberOfElements: number; @@ -69,5 +68,5 @@ export type { Pageable, SearchMeeting, IMeetingSearchCondition, - PaginatedSearchMeeting, + Paginated, }; diff --git a/src/types/myMeeting.ts b/src/types/myMeeting.ts new file mode 100644 index 0000000..8dece93 --- /dev/null +++ b/src/types/myMeeting.ts @@ -0,0 +1,78 @@ +import { IContactResponse } from './mypageTypes'; + +interface Member { + userId: number; + profilePic: string; + name: string; + memberStatus: 'APPROVED' | 'REJECTED' | 'PENDING' | 'EXPEL'; +} + +interface Meeting { + meetingId: number; + title: string; + thumbnail: string; + location: string; + memberCount: number; + maxMember: number; + isPublic: boolean; + memberList: Member[]; +} + +interface IMyMeetingManage { + meetingId: number; + title: string; + thumbnail: string; + location: string; + memberCount: number; + maxMember: number; + likesCount: number; + isPublic: boolean; + memberList: Member[]; +} + +interface IUserProfile { + userId: number; + name: string; + profilePic: string; + intro: string; + email: string; + position: string; + gender: string; + age: string; + location: string; + skillArray: string[]; + contactResponse: IContactResponse; +} + +interface IMemberProfile extends IUserProfile { + memberResponse: { + memberId: number; + message: string; + }; +} + +interface IBanner { + email: string; + name: string; + phone: string; + profilePic: string; + userId: number; +} + +interface UserData { + id: number; + name: string; + status: 'APPROVED' | 'REJECTED' | 'PENDING' | 'EXPEL'; + introduction: string; + profilePic: string; +} + +export type { + Member, + Meeting, + IMyMeetingManage, + IUserProfile, + IMemberProfile, + IBanner, + UserData, +};