diff --git a/src/frontend/src/api/directMessages.ts b/src/frontend/src/api/directMessages.ts new file mode 100644 index 00000000..8c4704f5 --- /dev/null +++ b/src/frontend/src/api/directMessages.ts @@ -0,0 +1,17 @@ +import { endPoint } from '@/constants/endPoint'; +import { GetDirectMessagesResponse, PostDirectMessageRequest } from '@/types/directMessages'; +import { tokenAxios } from '@/utils/axios'; + +export const getDirects = async () => { + const { data } = await tokenAxios.get(endPoint.directMessages.GET_DIRECTS); + return data.result.directResponses; +}; + +interface PostDirectParams { + bodyRequest: PostDirectMessageRequest; +} + +export const postDirect = async ({ bodyRequest }: PostDirectParams) => { + const { data } = await tokenAxios.post(endPoint.directMessages.POST_DIRECT, bodyRequest); + return data.result; +}; diff --git a/src/frontend/src/api/friends.ts b/src/frontend/src/api/friends.ts new file mode 100644 index 00000000..01bfe064 --- /dev/null +++ b/src/frontend/src/api/friends.ts @@ -0,0 +1,72 @@ +import { endPoint } from '@/constants/endPoint'; +import { + DeleteFriendResponse, + GetFriendsListResponse, + GetFriendInfoResponse, + GetReceivedRequestsResponse, + GetSentRequestsResponse, + PostAcceptResponse, + PostRejectResponse, + PostRequestResponse, +} from '@/types/friends'; +import { tokenAxios } from '@/utils/axios'; + +interface DeleteFriendParams { + friendId: string; +} + +export const deleteFriend = async ({ friendId }: DeleteFriendParams) => { + const { data } = await tokenAxios.delete(endPoint.friends.DELETE_FRIEND(friendId)); + return data; +}; + +interface GetFriendInfoParams { + email: string; +} + +export const getFriendInfo = async ({ email }: GetFriendInfoParams) => { + const { data } = await tokenAxios.get(endPoint.friends.GET_FRIENDS, { params: { email } }); + return data.result; +}; + +export const getSentRequest = async () => { + const { data } = await tokenAxios.get(endPoint.friends.GET_SENT_REQUESTS); + return data.result.friends; +}; + +export const getReceivedRequest = async () => { + const { data } = await tokenAxios.get(endPoint.friends.GET_RECEIVED_REQUESTS); + return data.result.friends; +}; + +export const getFriendsList = async () => { + const { data } = await tokenAxios.get(endPoint.friends.GET_FRIENDS_LIST); + return data.result.friends; +}; + +interface PostFriendRequestParams { + toUserId: string; +} + +export const postFriendRequest = async ({ toUserId }: PostFriendRequestParams) => { + const { data } = await tokenAxios.post(endPoint.friends.POST_REQUEST(toUserId)); + return data.result; +}; + +interface PostRejectRequestParams { + friendId: string; +} + +export const postRejectRequest = async ({ friendId }: PostRejectRequestParams) => { + const { data } = await tokenAxios.post(endPoint.friends.POST_REJECT_REQUEST(friendId)); + return data.result; +}; + +interface PostAcceptRequestParams { + friendId: string; +} + +export const postAcceptRequest = async ({ friendId }: PostAcceptRequestParams) => { + const { data } = await tokenAxios.post(endPoint.friends.POST_ACCEPT_REQUEST(friendId)); + return data.result; +}; diff --git a/src/frontend/src/api/users.ts b/src/frontend/src/api/users.ts index bdd8e3af..7428f6dd 100644 --- a/src/frontend/src/api/users.ts +++ b/src/frontend/src/api/users.ts @@ -1,5 +1,6 @@ import { endPoint } from '@/constants/endPoint'; import { + GetUserInfoResponse, PatchUserInfoRequest, PatchUserInfoResponse, PostAuthCodeRequest, @@ -12,6 +13,15 @@ import { } from '@/types/users'; import { publicAxios, tokenAxios } from '@/utils/axios'; +interface DeleteAccountParams { + userId: string; +} + +export const deleteAccount = async ({ userId }: DeleteAccountParams) => { + const { data } = await tokenAxios.delete(endPoint.users.DELETE_USER, { params: userId }); + return data; +}; + export const postLogin = async (requestBody: PostLoginRequest) => { const { data } = await publicAxios.post(endPoint.users.POST_SIGN_IN, requestBody); return data; @@ -38,12 +48,17 @@ export const postEmailDuplicate = async ({ email }: PostEmailDuplicateParams) => return data; }; +export const getUserInfo = async () => { + const { data } = await tokenAxios.get(endPoint.users.GET_USER_INFO); + return data; +}; + interface PatchUserInfoParams { userId: string; bodyRequest: PatchUserInfoRequest; } -export const PatchUserInfo = async ({ userId, bodyRequest }: PatchUserInfoParams) => { +export const patchUserInfo = async ({ userId, bodyRequest }: PatchUserInfoParams) => { const { data } = await publicAxios.patch(endPoint.users.PATCH_USER_INFO, bodyRequest, { params: userId, }); diff --git a/src/frontend/src/components/common/AuthCheckbox/index.tsx b/src/frontend/src/components/common/AuthCheckbox/index.tsx index 94ea655c..2d169a62 100644 --- a/src/frontend/src/components/common/AuthCheckbox/index.tsx +++ b/src/frontend/src/components/common/AuthCheckbox/index.tsx @@ -3,7 +3,7 @@ import CheckedIcon from '@/assets/checked.svg'; import * as S from './styles'; interface AuthCheckboxProps { - id: string; + id?: string; isChecked: boolean; handleChange?: () => void; label?: string; diff --git a/src/frontend/src/components/common/Modal/index.tsx b/src/frontend/src/components/common/Modal/index.tsx index f62bd498..7ce6d456 100644 --- a/src/frontend/src/components/common/Modal/index.tsx +++ b/src/frontend/src/components/common/Modal/index.tsx @@ -15,7 +15,7 @@ const Modal = ({ children, name }: ModalProps) => { return ( <> - closeModal(name, 'close-modal')} /> + closeModal(name, 'closeModal')} /> closeAllModal()} /> diff --git a/src/frontend/src/components/common/Modal/styles.ts b/src/frontend/src/components/common/Modal/styles.ts index ab0be9d6..d9b8536e 100644 --- a/src/frontend/src/components/common/Modal/styles.ts +++ b/src/frontend/src/components/common/Modal/styles.ts @@ -51,8 +51,7 @@ export const FooterWrapper = styled.div` align-items: center; justify-content: center; - margin-top: 1.6rem; - padding: 2.4rem 2.4rem 0; + padding: 0 2.4rem 2.4rem; `; export const CloseButton = styled.div` diff --git a/src/frontend/src/components/friend/AddFriendForm/index.tsx b/src/frontend/src/components/friend/AddFriendForm/index.tsx index 7b10f893..611baf71 100644 --- a/src/frontend/src/components/friend/AddFriendForm/index.tsx +++ b/src/frontend/src/components/friend/AddFriendForm/index.tsx @@ -1,5 +1,8 @@ import { useState } from 'react'; +import { getFriendInfo, getFriendsList, getReceivedRequest, getSentRequest, postFriendRequest } from '@/api/friends'; +import useGetUserInfo from '@/pages/FriendsPage/components/UserProfile/hooks/useGetUserInfo'; + import * as S from './styles'; export type ResultMessageType = 'success' | 'fail'; @@ -10,8 +13,7 @@ interface ResultMessage { } const AddFriendForm = () => { - // TODO: 친구 검색 및 요청 보내기 로직 연동 - // TODO: 요청 보내기 결과에 따라 resultMessage를 설정하고 표시 + const { userInfo } = useGetUserInfo(); const [inputData, setInputData] = useState(''); const [resultMessage, setResultMessage] = useState(null); @@ -19,8 +21,60 @@ const AddFriendForm = () => { setInputData(value); }; - const handleSendRequestButtonClick = () => { - console.log('친구 요청'); + const getFriendInfoByEmail = async () => { + try { + // 버튼을 눌렀을 때 요청 + const [friends, sentRequests, receivedRequests] = await Promise.all([ + getFriendsList(), + getSentRequest(), + getReceivedRequest(), + ]); + + // 1. 자신을 친구로 추가하려 하는지 확인 + if (userInfo?.email === inputData) { + setResultMessage({ type: 'fail', content: '본인은 추가할 수 없어요.' }); + return null; + } + + // 2. 이미 친구로 등록된 사용자인지 확인 + if (friends && friends.some((friend) => friend.email === inputData)) { + setResultMessage({ type: 'fail', content: '이미 친구로 등록된 사용자예요.' }); + return null; + } + + // 3. 이미 친구 요청을 보낸 사용자인지 확인 + if (sentRequests && sentRequests.some((request) => request.email === inputData)) { + setResultMessage({ type: 'fail', content: '이미 친구 요청을 보냈어요. 상대방의 응답을 기다려주세요.' }); + return null; + } + + // 4. 상대가 이미 친구 요청을 보냈는지 확인 + if (receivedRequests && receivedRequests.some((request) => request.email === inputData)) { + setResultMessage({ type: 'fail', content: '상대방이 이미 친구 요청을 보냈어요. 친구 요청을 확인해주세요.' }); + return null; + } + + const response = await getFriendInfo({ email: inputData }); + return response; + } catch (error) { + console.log('회원 검색 실패', error); + setResultMessage({ type: 'fail', content: '회원 검색에 실패했어요. 다시 시도해주세요.' }); + return null; + } + }; + + const handleSendRequestButtonClick = async () => { + try { + const friendInfo = await getFriendInfoByEmail(); + if (!friendInfo) return; + + await postFriendRequest({ toUserId: friendInfo.userId }); + + setResultMessage({ type: 'success', content: '친구 요청을 보냈어요!' }); + } catch (error) { + console.log('친구 요청 실패', error); + setResultMessage({ type: 'fail', content: '친구 요청을 보내는 데 실패했어요. 다시 시도해주세요.' }); + } }; return ( diff --git a/src/frontend/src/components/friend/AddFriendForm/styles.ts b/src/frontend/src/components/friend/AddFriendForm/styles.ts index 1f0f0c37..d6e61773 100644 --- a/src/frontend/src/components/friend/AddFriendForm/styles.ts +++ b/src/frontend/src/components/friend/AddFriendForm/styles.ts @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { BodyRegularText, ChipText, TitleText2 } from '@/styles/Typography'; +import { BodyRegularText, TitleText2 } from '@/styles/Typography'; import { ResultMessageType } from '.'; diff --git a/src/frontend/src/components/friend/CreateDirectMessageModal/hooks/usePostDirect.ts b/src/frontend/src/components/friend/CreateDirectMessageModal/hooks/usePostDirect.ts new file mode 100644 index 00000000..f6b90eb8 --- /dev/null +++ b/src/frontend/src/components/friend/CreateDirectMessageModal/hooks/usePostDirect.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { postDirect } from '@/api/directMessages'; +import useModalStore from '@/stores/modalStore'; + +const usePostDirect = () => { + const queryClient = useQueryClient(); + const { closeAllModal } = useModalStore(); + + const createDirectMessageMutation = useMutation({ + mutationFn: postDirect, + mutationKey: ['createDirectMessage'], + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['directMessagesList'] }); + closeAllModal(); + }, + onError: (error) => { + console.error('DM 생성 실패', error); + }, + }); + + const createDirectMessage = (memberIds: string[]) => { + createDirectMessageMutation.mutate({ bodyRequest: { memberIds } }); + }; + + return { createDirectMessage, isPending: createDirectMessageMutation.isPending }; +}; + +export default usePostDirect; diff --git a/src/frontend/src/components/friend/CreateDirectMessageModal/index.tsx b/src/frontend/src/components/friend/CreateDirectMessageModal/index.tsx new file mode 100644 index 00000000..ec433f11 --- /dev/null +++ b/src/frontend/src/components/friend/CreateDirectMessageModal/index.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; + +import AuthCheckbox from '@/components/common/AuthCheckbox'; +import Modal from '@/components/common/Modal'; + +import useGetFriendsList from '../FriendsList/hooks/useGetFriendsList'; + +import usePostDirect from './hooks/usePostDirect'; +import * as S from './styles'; + +const MAX_DIRECT_MESSAGE_FRIENDS = 9; + +const CreateDirectMessageModal = () => { + const [selectedFriends, setSelectedFriends] = useState([]); + const { createDirectMessage, isPending } = usePostDirect(); + const { friends } = useGetFriendsList(); + if (!friends) return null; + + const handleCheckboxClick = (userId: string) => { + // 이미 선택된 친구를 누르면 선택 해제 가능 + if (selectedFriends.length === MAX_DIRECT_MESSAGE_FRIENDS) { + if (!selectedFriends.includes(userId)) return; + } + + setSelectedFriends((prev) => (prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId])); + }; + + const handleCreateDirectMessage = async () => { + createDirectMessage(selectedFriends); + }; + + const isButtonDisabled = !selectedFriends.length || isPending; + + return ( + + + 친구 선택하기 + + 친구를 {MAX_DIRECT_MESSAGE_FRIENDS - selectedFriends.length}명 더 선택할 수 있어요. + + + + + {friends.map((friend) => ( + handleCheckboxClick(friend.userId)}> + + + + + {friend.nickname} + {friend.name} + + + + ))} + + + + + {isPending ? '요청 중...' : 'DM 생성'} + + + + ); +}; + +export default CreateDirectMessageModal; diff --git a/src/frontend/src/components/friend/CreateDirectMessageModal/styles.ts b/src/frontend/src/components/friend/CreateDirectMessageModal/styles.ts new file mode 100644 index 00000000..d11c8ab5 --- /dev/null +++ b/src/frontend/src/components/friend/CreateDirectMessageModal/styles.ts @@ -0,0 +1,102 @@ +import styled from 'styled-components'; + +import { BodyMediumText, BodyRegularText, ChipText } from '@/styles/Typography'; + +export const ModalTitle = styled(BodyMediumText)` + font-size: 2rem; +`; + +export const SelectedFriendsCount = styled(ChipText)` + color: ${({ theme }) => theme.colors.dark[300]}; +`; + +export const FriendList = styled.ul` + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.1rem; + + height: fit-content; + max-height: 27rem; + + &::-webkit-scrollbar-thumb { + border: 0.1rem solid ${({ theme }) => theme.colors.dark[600]}; + border-radius: 0.3rem; + background: ${({ theme }) => theme.colors.dark[800]}; + } + + &::-webkit-scrollbar { + width: 0.6rem; + } +`; + +export const FriendItem = styled.li` + cursor: pointer; + + display: flex; + align-items: center; + + min-height: 4rem; + padding: 0 0.8rem; + border-radius: 0.4rem; + + &:hover { + background-color: ${({ theme }) => theme.colors.dark[600]}; + } +`; + +export const FriendProfileImage = styled.div<{ $imageUrl: string }>` + position: relative; + + width: 3.2rem; + height: 3.2rem; + margin-right: 1.2rem; + border-radius: 50%; + + background-color: ${({ theme }) => theme.colors.white}; + background-image: url(${(props) => props.$imageUrl}); +`; + +export const FriendStatusMark = styled.div<{ $isOnline: boolean }>` + position: absolute; + right: 0; + bottom: 0; + + width: 1rem; + height: 1rem; + border: 0.1rem solid ${({ theme }) => theme.colors.dark[400]}; + border-radius: 50%; + + background-color: ${({ theme, $isOnline }) => ($isOnline ? theme.colors.online : theme.colors.black)}; +`; + +export const FriendInfo = styled.div` + display: flex; + flex-grow: 1; + gap: 0.4rem; + align-items: center; + + min-width: 0; +`; + +export const FriendNickname = styled(BodyRegularText)` + color: ${({ theme }) => theme.colors.white}; +`; + +export const FriendName = styled(ChipText)` + line-height: 1.4rem; + color: ${({ theme }) => theme.colors.dark[300]}; +`; + +export const CreateButton = styled.button<{ $isDisabled: boolean }>` + cursor: ${({ $isDisabled }) => ($isDisabled ? 'not-allowed' : 'pointer')}; + + width: 100%; + height: 3.8rem; + border-radius: 0.4rem; + + color: ${({ theme }) => theme.colors.white}; + + opacity: ${({ $isDisabled }) => ($isDisabled ? 0.5 : 1)}; + background-color: ${({ theme }) => theme.colors.blue}; +`; diff --git a/src/frontend/src/components/friend/DeleteFriendConfirmModal/hooks/useDeleteFriend.ts b/src/frontend/src/components/friend/DeleteFriendConfirmModal/hooks/useDeleteFriend.ts new file mode 100644 index 00000000..c95a37e2 --- /dev/null +++ b/src/frontend/src/components/friend/DeleteFriendConfirmModal/hooks/useDeleteFriend.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { deleteFriend } from '@/api/friends'; + +const useDeleteFriend = () => { + const queryClient = useQueryClient(); + + const deleteFriendMutation = useMutation({ + mutationFn: (friendId: string) => deleteFriend({ friendId }), + mutationKey: ['deleteFriend'], + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['friendsList'] }); + }, + onError: (error) => { + console.error('친구 삭제 실패', error); + }, + }); + + const deleteFriendMutate = (friendId: string) => { + deleteFriendMutation.mutate(friendId); + }; + + return { deleteFriend: deleteFriendMutate, isPending: deleteFriendMutation.isPending }; +}; + +export default useDeleteFriend; diff --git a/src/frontend/src/components/friend/DeleteFriendConfirmModal/index.tsx b/src/frontend/src/components/friend/DeleteFriendConfirmModal/index.tsx new file mode 100644 index 00000000..28a15025 --- /dev/null +++ b/src/frontend/src/components/friend/DeleteFriendConfirmModal/index.tsx @@ -0,0 +1,33 @@ +import Modal from '@/components/common/Modal'; +import useModalStore from '@/stores/modalStore'; + +import useDeleteFriend from './hooks/useDeleteFriend'; +import * as S from './styles'; + +interface DeleteFriendConfirmModalProps { + friendId: string; + friendNickname: string; +} + +const DeleteFriendConfirmModal = ({ friendId, friendNickname }: DeleteFriendConfirmModalProps) => { + const { closeAllModal } = useModalStore(); + const { deleteFriend, isPending } = useDeleteFriend(); + + return ( + + + + 정말 {friendNickname} 님을 친구에서 삭제하시겠어요? + + + + 취소 + deleteFriend(friendId)}> + 친구 삭제하기 + + + + ); +}; + +export default DeleteFriendConfirmModal; diff --git a/src/frontend/src/components/friend/DeleteFriendConfirmModal/styles.ts b/src/frontend/src/components/friend/DeleteFriendConfirmModal/styles.ts new file mode 100644 index 00000000..875c9f9c --- /dev/null +++ b/src/frontend/src/components/friend/DeleteFriendConfirmModal/styles.ts @@ -0,0 +1,45 @@ +import styled from 'styled-components'; + +import { BodyMediumText } from '@/styles/Typography'; + +export const ConfirmText = styled(BodyMediumText)` + color: ${({ theme }) => theme.colors.white}; +`; + +export const ModalButtonContainer = styled.div` + display: flex; + gap: 1.6rem; + align-items: center; + justify-content: flex-end; + + height: 7rem; + padding: 1.6rem; + + background-color: ${({ theme }) => theme.colors.dark[600]}; +`; + +export const CancelButton = styled.button` + height: 3.8rem; + padding: 0.2rem 1.6rem; + color: ${({ theme }) => theme.colors.white}; + + &:hover { + text-decoration: underline; + } +`; + +export const DeleteButton = styled.button<{ $isPending: boolean }>` + cursor: ${({ $isPending }) => $isPending && 'not-allowed'}; + + height: 3.8rem; + padding: 0.2rem 1.6rem; + border-radius: 0.4rem; + + color: ${({ theme }) => theme.colors.white}; + + background-color: ${({ theme }) => theme.colors.red}; + + &:hover { + opacity: 0.8; + } +`; diff --git a/src/frontend/src/components/friend/DirectMessageCategory/index.tsx b/src/frontend/src/components/friend/DirectMessageCategory/index.tsx index c964346e..0001caa0 100644 --- a/src/frontend/src/components/friend/DirectMessageCategory/index.tsx +++ b/src/frontend/src/components/friend/DirectMessageCategory/index.tsx @@ -1,12 +1,33 @@ +import { MdPerson } from 'react-icons/md'; + +import { useChannelInfoStore } from '@/stores/channelInfo'; +import { BodyMediumText } from '@/styles/Typography'; + +import DirectMessagesList from '../DirectMessagesList'; + import * as S from './styles'; const DirectMessageCategory = () => { + const { selectedDMChannel, setSelectedDMChannel } = useChannelInfoStore(); + + const handleFriendsTabClick = () => { + setSelectedDMChannel(null); + }; + return ( <> 다이렉트 메시지 - {/* TODO: 친구 채팅방 목록 */} + + + + + + 친구 + + + ); }; diff --git a/src/frontend/src/components/friend/DirectMessageCategory/styles.ts b/src/frontend/src/components/friend/DirectMessageCategory/styles.ts index 2ee1f2f1..ff2adce6 100644 --- a/src/frontend/src/components/friend/DirectMessageCategory/styles.ts +++ b/src/frontend/src/components/friend/DirectMessageCategory/styles.ts @@ -16,3 +16,40 @@ export const FriendTitle = styled.div` export const TitleName = styled(TitleText2)` color: ${({ theme }) => theme.colors.dark[300]}; `; + +export const TabList = styled.ul` + display: flex; + gap: 0.1rem; + padding: 0.8rem 0.8rem 0; + list-style: none; +`; + +export const TabItem = styled.li<{ $isSelected: boolean }>` + cursor: pointer; + + display: flex; + flex-grow: 1; + align-items: center; + + height: 4.4rem; + padding: 0 0.8rem; + border-radius: 0.4rem; + + background-color: ${({ theme, $isSelected }) => ($isSelected ? theme.colors.dark[500] : '')}; + + & > * { + color: ${({ theme, $isSelected }) => ($isSelected ? theme.colors.white : theme.colors.dark[300])}; + } + + &:hover { + background-color: ${({ theme }) => theme.colors.dark[500]}; + + & > * { + color: ${({ theme }) => theme.colors.white}; + } + } +`; + +export const TabIcon = styled.div` + margin-right: 1.2rem; +`; diff --git a/src/frontend/src/components/friend/DirectMessagesList/index.tsx b/src/frontend/src/components/friend/DirectMessagesList/index.tsx new file mode 100644 index 00000000..96b7ed68 --- /dev/null +++ b/src/frontend/src/components/friend/DirectMessagesList/index.tsx @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query'; +import { TbPlus } from 'react-icons/tb'; + +import { getDirects } from '@/api/directMessages'; +import { useChannelInfoStore } from '@/stores/channelInfo'; +import useModalStore from '@/stores/modalStore'; +import { useUserInfoStore } from '@/stores/userInfo'; +import { ChipText } from '@/styles/Typography'; + +import CreateDirectMessageModal from '../CreateDirectMessageModal'; + +import * as S from './styles'; + +const DirectMessagesList = () => { + const { openModal } = useModalStore(); + const { userInfo } = useUserInfoStore(); + const { selectedDMChannel, setSelectedDMChannel } = useChannelInfoStore(); + const { data: directMessages } = useQuery({ queryKey: ['directMessagesList'], queryFn: getDirects }); + if (!directMessages) return null; + + const handleCreateDirectMessageButtonClick = () => { + openModal('withFooter', ); + }; + + return ( + + + 다이렉트 메시지 + + + + {directMessages.map(({ directId, members }) => { + const otherMemberList = members.responses.filter((member) => member.userId !== userInfo?.userId); + const directMessageName = otherMemberList.map((member) => member.nickname).join(', '); + + return ( + setSelectedDMChannel({ id: directId, name: directMessageName, type: 'TEXT' })} + > + + {members.responses.length === 2 && } + + + {directMessageName} + {members.responses.length > 2 && 멤버 {members.responses.length}명} + + + ); + })} + + + ); +}; + +export default DirectMessagesList; diff --git a/src/frontend/src/components/friend/DirectMessagesList/styles.ts b/src/frontend/src/components/friend/DirectMessagesList/styles.ts new file mode 100644 index 00000000..68dde7b9 --- /dev/null +++ b/src/frontend/src/components/friend/DirectMessagesList/styles.ts @@ -0,0 +1,104 @@ +import styled from 'styled-components'; + +import { BodyMediumText } from '@/styles/Typography'; + +export const DirectMessagesListContainer = styled.div` + display: flex; + flex-direction: column; + color: ${({ theme }) => theme.colors.dark[300]}; +`; + +export const CategoryName = styled.div` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + padding: 1.8rem 1.6rem 0.4rem; + + svg { + color: ${({ theme }) => theme.colors.dark[300]}; + } + + &:hover { + * { + color: ${({ theme }) => theme.colors.white}; + } + } +`; + +export const DirectMessagesList = styled.ul` + display: flex; + flex-direction: column; + gap: 0.1rem; + + width: 100%; + margin-top: 0.6rem; + padding: 0 0.8rem; +`; + +export const DirectMessageItem = styled.li<{ $isSelected: boolean }>` + cursor: pointer; + + display: flex; + align-items: center; + + width: 100%; + height: 4.4rem; + padding: 0 0.8rem; + border-radius: 0.4rem; + + color: ${({ theme, $isSelected }) => ($isSelected ? theme.colors.white : theme.colors.dark[300])}; + + background-color: ${({ theme, $isSelected }) => $isSelected && theme.colors.dark[500]}; + + &:hover { + color: ${({ theme }) => theme.colors.white}; + } +`; + +export const DirectMessageImage = styled.div<{ $userImageUrl: string }>` + position: relative; + + min-width: 3.2rem; + height: 3.2rem; + margin-right: 1.2rem; + border-radius: 50%; + + background-color: ${({ theme }) => theme.colors.white}; + background-image: url(${(props) => props.$userImageUrl}); + background-repeat: no-repeat; + background-size: cover; +`; + +export const UserStatusMark = styled.div<{ $isOnline: boolean }>` + position: absolute; + right: 0; + bottom: 0; + + width: 1rem; + height: 1rem; + border: 0.1rem solid ${({ theme }) => theme.colors.dark[400]}; + border-radius: 50%; + + background-color: ${({ theme, $isOnline }) => ($isOnline ? theme.colors.online : theme.colors.black)}; +`; + +export const DirectMessageInfo = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; +`; + +export const DirectMessageName = styled(BodyMediumText)` + overflow: hidden; + display: block; + + width: 100%; + + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/frontend/src/components/friend/FriendsList/hooks/useGetFriendsList.ts b/src/frontend/src/components/friend/FriendsList/hooks/useGetFriendsList.ts new file mode 100644 index 00000000..15b7892f --- /dev/null +++ b/src/frontend/src/components/friend/FriendsList/hooks/useGetFriendsList.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getFriendsList } from '@/api/friends'; + +const useGetFriendsList = () => { + const result = useQuery({ queryKey: ['friendsList'], queryFn: getFriendsList, staleTime: 10 * 60 * 1000 }); + + return { + friends: result.data, + ...result, + }; +}; + +export default useGetFriendsList; diff --git a/src/frontend/src/components/friend/FriendsList/index.tsx b/src/frontend/src/components/friend/FriendsList/index.tsx index a024b694..63ca411b 100644 --- a/src/frontend/src/components/friend/FriendsList/index.tsx +++ b/src/frontend/src/components/friend/FriendsList/index.tsx @@ -1,43 +1,45 @@ -import * as S from './styles'; +import { TiDelete } from 'react-icons/ti'; + +import useModalStore from '@/stores/modalStore'; -// TODO: API 문서에 맞게 수정 및 types 폴더로 이동 -export interface Friend { - name: string; - profileImageUrl: string; - isOnline: boolean; -} +import DeleteFriendConfirmModal from '../DeleteFriendConfirmModal'; -// TODO: useQuery로 친구 리스트 요청 -const friends: Friend[] = [ - { - name: '친구1', - profileImageUrl: '', - isOnline: true, - }, - { - name: '친구2', - profileImageUrl: '', - isOnline: true, - }, - { - name: '친구3', - profileImageUrl: '', - isOnline: false, - }, -]; +import useGetFriendsList from './hooks/useGetFriendsList'; +import * as S from './styles'; const FriendsList = () => { + // TODO: 상태 동적으로 변경하기 + // TODO: 내 요청을 상대방이 수락할 경우 invalidate + const { friends } = useGetFriendsList(); + const { openModal } = useModalStore(); + + const handleDeleteFriendButtonClick = async (friendId: string, friendNickname: string) => { + openModal('withFooter', ); + }; + return ( - 모든 친구 - {friends.length}명 - {friends.map((friend, index) => ( - - - - - {friend.name} - - ))} + {friends && ( + <> + 모든 친구 - {friends.length}명 + {friends.map((friend) => ( + + + + + + {friend.nickname} + 온라인 + + + handleDeleteFriendButtonClick(friend.userId, friend.nickname)}> + + + + + ))} + + )} ); }; diff --git a/src/frontend/src/components/friend/FriendsList/styles.ts b/src/frontend/src/components/friend/FriendsList/styles.ts index ec2bb36f..22eb272b 100644 --- a/src/frontend/src/components/friend/FriendsList/styles.ts +++ b/src/frontend/src/components/friend/FriendsList/styles.ts @@ -25,6 +25,7 @@ export const FriendItem = styled.li` align-items: center; height: 6.2rem; + padding: 0 0.8rem; border-top: 0.1rem solid ${({ theme }) => theme.colors.dark[500]}; border-radius: 0.4rem; @@ -38,6 +39,7 @@ export const FriendProfileImage = styled.div<{ $imageUrl: string }>` width: 3.2rem; height: 3.2rem; + margin-right: 1.2rem; border-radius: 50%; background-color: ${({ theme }) => theme.colors.white}; @@ -54,14 +56,18 @@ export const FriendStatusMark = styled.div<{ $isOnline: boolean }>` border: 0.1rem solid ${({ theme }) => theme.colors.dark[400]}; border-radius: 50%; - background-color: ${({ $isOnline }) => ($isOnline ? 'green' : 'black')}; + background-color: ${({ theme, $isOnline }) => ($isOnline ? theme.colors.online : theme.colors.black)}; `; -export const FriendName = styled(BodyMediumText)` - overflow: hidden; - - padding-left: 0.8rem; +export const FriendInfo = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; +`; +export const FriendNickname = styled(BodyMediumText)` + overflow: hidden; color: ${({ theme }) => theme.colors.white}; text-overflow: ellipsis; white-space: nowrap; @@ -70,3 +76,17 @@ export const FriendName = styled(BodyMediumText)` export const FriendStatus = styled(ChipText)` color: ${({ theme }) => theme.colors.dark[300]}; `; + +export const IconContainer = styled.div` + display: flex; + padding: 0 0.8rem; +`; + +export const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + + width: 3.2rem; + height: 3.2rem; +`; diff --git a/src/frontend/src/components/friend/PendingFriendsList/hooks/useGetReceivedRequests.ts b/src/frontend/src/components/friend/PendingFriendsList/hooks/useGetReceivedRequests.ts new file mode 100644 index 00000000..ab5169ac --- /dev/null +++ b/src/frontend/src/components/friend/PendingFriendsList/hooks/useGetReceivedRequests.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getReceivedRequest } from '@/api/friends'; + +const useGetReceivedRequests = () => { + const result = useQuery({ queryKey: ['receivedRequests'], queryFn: getReceivedRequest }); + + return { + receivedRequests: result.data, + ...result, + }; +}; + +export default useGetReceivedRequests; diff --git a/src/frontend/src/components/friend/PendingFriendsList/hooks/useGetSentRequests.ts b/src/frontend/src/components/friend/PendingFriendsList/hooks/useGetSentRequests.ts new file mode 100644 index 00000000..9abf4a78 --- /dev/null +++ b/src/frontend/src/components/friend/PendingFriendsList/hooks/useGetSentRequests.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getSentRequest } from '@/api/friends'; + +const useGetSentRequests = () => { + const result = useQuery({ queryKey: ['sentRequests'], queryFn: getSentRequest }); + + return { + sentRequests: result.data, + ...result, + }; +}; + +export default useGetSentRequests; diff --git a/src/frontend/src/components/friend/PendingFriendsList/hooks/useHandleFriendRequest.ts b/src/frontend/src/components/friend/PendingFriendsList/hooks/useHandleFriendRequest.ts new file mode 100644 index 00000000..f1fcfeeb --- /dev/null +++ b/src/frontend/src/components/friend/PendingFriendsList/hooks/useHandleFriendRequest.ts @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { postAcceptRequest, postRejectRequest } from '@/api/friends'; + +import usePostDirect from '../../CreateDirectMessageModal/hooks/usePostDirect'; + +const useHandleFriendRequest = () => { + const queryClient = useQueryClient(); + const [errorMessage, setErrorMessage] = useState(null); + const { createDirectMessage } = usePostDirect(); + + const acceptRequestMutation = useMutation({ + mutationKey: ['acceptRequest'], + mutationFn: postAcceptRequest, + onSuccess: (_, params) => { + queryClient.invalidateQueries({ queryKey: ['friendsList'] }); + queryClient.invalidateQueries({ queryKey: ['receivedRequests'] }); + setErrorMessage(null); + + if (params.friendId) createDirectMessage([params.friendId]); + }, + onError: () => { + setErrorMessage('요청 수락에 실패했어요. 다시 시도해 주세요.'); + }, + }); + + const rejectRequestMutation = useMutation({ + mutationKey: ['rejectRequest'], + mutationFn: postRejectRequest, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['receivedRequests'] }); + setErrorMessage(null); + }, + onError: () => { + setErrorMessage('요청 거절에 실패했어요. 다시 시도해 주세요.'); + }, + }); + + return { acceptRequestMutation, rejectRequestMutation, errorMessage }; +}; + +export default useHandleFriendRequest; diff --git a/src/frontend/src/components/friend/PendingFriendsList/index.tsx b/src/frontend/src/components/friend/PendingFriendsList/index.tsx index 20cbcfb2..4ca6dd4e 100644 --- a/src/frontend/src/components/friend/PendingFriendsList/index.tsx +++ b/src/frontend/src/components/friend/PendingFriendsList/index.tsx @@ -1,52 +1,72 @@ -import { Friend } from '../FriendsList'; - +import useGetReceivedRequests from './hooks/useGetReceivedRequests'; +import useGetSentRequests from './hooks/useGetSentRequests'; +import useHandleFriendRequest from './hooks/useHandleFriendRequest'; import * as S from './styles'; -const friends: Friend[] = [ - { - name: '친구1', - profileImageUrl: '', - isOnline: true, - }, - { - name: '친구2', - profileImageUrl: '', - isOnline: true, - }, -]; - const PendingFriendsList = () => { - // TODO: 받은 친구 및 보낸 친구 요청 리스트 보여주기 - // TODO: 받은 친구 요청인 경우 수락 및 거절 로직 구현 - // TODO: 보낸 친구 요청인 경우 요청 취소 로직 구현 - // 현재는 받은 친구 요청 UI만 구현 + const { receivedRequests } = useGetReceivedRequests(); + const { sentRequests } = useGetSentRequests(); + const { acceptRequestMutation, rejectRequestMutation, errorMessage } = useHandleFriendRequest(); - const handleAcceptButtonClick = () => { - console.log('친구 요청 수락'); + const handleAcceptButtonClick = (friendId: string) => { + acceptRequestMutation.mutate({ friendId }); }; - const handleRejectButtonClick = () => { - console.log('친구 요청 거절'); + const handleRejectButtonClick = (friendId: string) => { + rejectRequestMutation.mutate({ friendId }); }; return ( - - 대기 중인 친구 - {friends.length}명 - {friends.map((friend, index) => ( - - - - - - {friend.name} - - - 수락하기 - 거절하기 - - - ))} - + + {receivedRequests && ( + <> + + 받은 요청 - {receivedRequests.length}건 + {receivedRequests.map((friend) => ( + <> + + + + {friend.nickname} + + + handleAcceptButtonClick(friend.userId)} + > + {acceptRequestMutation.isPending ? '처리 중...' : '수락하기'} + + handleRejectButtonClick(friend.userId)} + > + {rejectRequestMutation.isPending ? '처리 중...' : '거절하기'} + + + + {errorMessage && {errorMessage}} + + ))} + + {receivedRequests.length > 0 && } + + )} + {sentRequests && ( + <> + + 보낸 요청 - {sentRequests.length}건 + {sentRequests.map((friend) => ( + + + + {friend.nickname} + + + ))} + + + )} + ); }; diff --git a/src/frontend/src/components/friend/PendingFriendsList/styles.ts b/src/frontend/src/components/friend/PendingFriendsList/styles.ts index afa85081..50fbf50b 100644 --- a/src/frontend/src/components/friend/PendingFriendsList/styles.ts +++ b/src/frontend/src/components/friend/PendingFriendsList/styles.ts @@ -2,6 +2,12 @@ import styled from 'styled-components'; import { BodyMediumText, ChipText } from '@/styles/Typography'; +export const PendingFriendsListContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + export const FriendsList = styled.ul` display: flex; flex-direction: column; @@ -26,6 +32,7 @@ export const FriendItem = styled.li` justify-content: space-between; height: 6.2rem; + padding: 0 0.8rem; border-top: 0.1rem solid ${({ theme }) => theme.colors.dark[500]}; border-radius: 0.4rem; @@ -60,10 +67,10 @@ export const FriendStatusMark = styled.div<{ $isOnline: boolean }>` border: 0.1rem solid ${({ theme }) => theme.colors.dark[400]}; border-radius: 50%; - background-color: ${({ $isOnline }) => ($isOnline ? 'green' : 'black')}; + background-color: ${({ theme, $isOnline }) => ($isOnline ? theme.colors.online : theme.colors.black)}; `; -export const FriendName = styled(BodyMediumText)` +export const FriendNickname = styled(BodyMediumText)` overflow: hidden; padding-left: 0.8rem; @@ -82,7 +89,10 @@ export const ButtonContainer = styled.div` gap: 0.5rem; `; -export const AcceptButton = styled.button` +export const AcceptButton = styled.button<{ $isPending: boolean }>` + pointer-events: ${({ $isPending }) => ($isPending ? 'none' : 'auto')}; + cursor: ${({ $isPending }) => ($isPending ? 'not-allowed' : 'pointer')}; + width: 8rem; height: 3.2rem; border-radius: 0.4rem; @@ -93,7 +103,10 @@ export const AcceptButton = styled.button` background-color: ${({ theme }) => theme.colors.green}; `; -export const RejectButton = styled.button` +export const RejectButton = styled.button<{ $isPending: boolean }>` + pointer-events: ${({ $isPending }) => ($isPending ? 'none' : 'auto')}; + cursor: ${({ $isPending }) => ($isPending ? 'not-allowed' : 'pointer')}; + width: 8rem; height: 3.2rem; border-radius: 0.4rem; @@ -103,3 +116,15 @@ export const RejectButton = styled.button` background-color: ${({ theme }) => theme.colors.red}; `; + +export const Divider = styled.hr` + margin: 0 2rem 0 3rem; + border: 0; + border-top: 0.1rem solid ${({ theme }) => theme.colors.dark[500]}; +`; + +export const ErrorMessage = styled.div` + display: flex; + justify-content: flex-end; + color: ${({ theme }) => theme.colors.red}; +`; diff --git a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx index 8f5afb09..130ab5d5 100644 --- a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx +++ b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx @@ -5,7 +5,7 @@ import { TbPlus } from 'react-icons/tb'; import { GuildChannelInfo, useChannelInfoStore } from '@/stores/channelInfo'; import { useGuildInfoStore } from '@/stores/guildInfo'; import useModalStore from '@/stores/modalStore'; -import { BodyMediumText, BodyRegularText } from '@/styles/Typography'; +import { BodyMediumText, BodyRegularText, ChipText } from '@/styles/Typography'; import { CategoryDataResult, ChannelResult, ChannelType } from '@/types/guilds'; import CreateChannelModal from '../CreateChannelModal'; @@ -34,7 +34,7 @@ const GuildCategoriesList = ({ categories, channels }: CategoriesListProps) => { {categories?.map((category) => ( - {category.name} + {category.name} handleOpenModal(category.categoryId, guildId)} /> diff --git a/src/frontend/src/constants/endPoint.ts b/src/frontend/src/constants/endPoint.ts index 915a6702..aa98c4a0 100644 --- a/src/frontend/src/constants/endPoint.ts +++ b/src/frontend/src/constants/endPoint.ts @@ -2,14 +2,31 @@ export const endPoint = { users: { // User API DELETE_USER: '/users/auth', + GET_USER_INFO: '/users/info', PATCH_USER_INFO: '/users/info', PATCH_DEVICE_TOKEN: '/users/device-token', + + // Login, Registration API POST_VALIDATION_EMAIL: '/users/validation/email', POST_AUTHENTICATION_CODE: '/users/validation/authentication-code', POST_SIGN_UP: '/users/sign-up', POST_SIGN_IN: '/users/sign-in', }, + friends: { + // Friends API + DELETE_FRIEND: (friendId: string) => `/users/friends/${friendId}`, + GET_FRIENDS: '/users/friends', + GET_FRIENDS_LIST: '/users/friends/list', + + // Friends Request API + GET_SENT_REQUESTS: '/users/friends/sent', + GET_RECEIVED_REQUESTS: '/users/friends/received', + POST_REQUEST: (toUserId: string) => `/users/friends/request/${toUserId}`, + POST_REJECT_REQUEST: (friendId: string) => `/users/friends/reject/${friendId}`, + POST_ACCEPT_REQUEST: (friendId: string) => `/users/friends/accept/${friendId}`, + }, + guilds: { // Guild Category API DELETE_CATEGORY: (guildId: string, categoryId: string) => `/guilds/category/${guildId}/${categoryId}`, @@ -36,4 +53,10 @@ export const endPoint = { ACCEPT_GUILD_INVITATION: (guildId: string) => `/guilds/guilds/${guildId}/invitations/accept`, SEND_INVITATION: (guildId: string) => `/guilds/guilds/${guildId}/invitations`, }, + + directMessages: { + // Direct Message API + GET_DIRECTS: '/guilds/direct', + POST_DIRECT: '/guilds/direct', + }, }; diff --git a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx index 67f1fa84..d6f109ec 100644 --- a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx @@ -11,15 +11,7 @@ const CategorySection = () => { return ( {guildId ? : } - {}} - handleHeadsetToggle={() => {}} - /> + {}} handleHeadsetToggle={() => {}} /> ); }; diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfile/hooks/useGetUserInfo.ts b/src/frontend/src/pages/FriendsPage/components/UserProfile/hooks/useGetUserInfo.ts new file mode 100644 index 00000000..ce8245cd --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/UserProfile/hooks/useGetUserInfo.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { getUserInfo } from '@/api/users'; +import { useUserInfoStore } from '@/stores/userInfo'; + +const useGetUserInfo = () => { + const { userInfo, setUserInfo, clearUserInfo } = useUserInfoStore(); + + const queryResult = useQuery({ + queryKey: ['userInfo'], + queryFn: getUserInfo, + staleTime: 14 * 24 * 60 * 60 * 1000, + }); + + useEffect(() => { + if (queryResult.data) { + const newUserId = queryResult.data.result.userId; + if (newUserId !== userInfo?.userId) setUserInfo({ userId: queryResult.data.result.userId }); + } + }, [queryResult.data, setUserInfo, userInfo]); + + useEffect(() => { + if (queryResult.isError) clearUserInfo(); + }, [queryResult.isError, clearUserInfo]); + + return { userInfo: queryResult.data?.result, ...queryResult }; +}; + +export default useGetUserInfo; diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfile/index.tsx b/src/frontend/src/pages/FriendsPage/components/UserProfile/index.tsx index f04a02ab..4c52caab 100644 --- a/src/frontend/src/pages/FriendsPage/components/UserProfile/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/UserProfile/index.tsx @@ -6,39 +6,32 @@ import useDropdown from '@/hooks/useDropdown'; import UserProfileTab from '../UserProfileTab'; +import useGetUserInfo from './hooks/useGetUserInfo'; import * as S from './styles'; +// TODO: 마이크, 헤드셋 전역 상태로 관리 interface UserProfileProps { - userImageUrl: string; - userName: string; - isOnline: boolean; isMicOn: boolean; isHeadsetOn: boolean; handleMicToggle: () => void; handleHeadsetToggle: () => void; } -// TODO: props로 받고 있는 정보들을 전역 상태 및 사용자 정보 요청으로 대체 -const UserProfile = ({ - userImageUrl, - userName, - isOnline, - isMicOn, - isHeadsetOn, - handleMicToggle, - handleHeadsetToggle, -}: UserProfileProps) => { +const UserProfile = ({ isMicOn, isHeadsetOn, handleMicToggle, handleHeadsetToggle }: UserProfileProps) => { const { isOpened, dropdownRef, toggleDropdown } = useDropdown(); + const { userInfo } = useGetUserInfo(); + if (!userInfo) return null; + return ( - - + + - {userName} - {isOnline ? '온라인' : '오프라인'} + {userInfo.nickname} + 온라인 diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfile/styles.ts b/src/frontend/src/pages/FriendsPage/components/UserProfile/styles.ts index 6450267f..4e7a5c45 100644 --- a/src/frontend/src/pages/FriendsPage/components/UserProfile/styles.ts +++ b/src/frontend/src/pages/FriendsPage/components/UserProfile/styles.ts @@ -38,6 +38,8 @@ export const UserImage = styled.div<{ $userImageUrl: string }>` background-color: ${({ theme }) => theme.colors.white}; background-image: url(${(props) => props.$userImageUrl}); + background-repeat: no-repeat; + background-size: contain; `; export const UserStatusMark = styled.div<{ $isOnline: boolean }>` @@ -50,7 +52,7 @@ export const UserStatusMark = styled.div<{ $isOnline: boolean }>` border: 0.1rem solid ${({ theme }) => theme.colors.dark[400]}; border-radius: 50%; - background-color: ${({ $isOnline }) => ($isOnline ? 'green' : 'black')}; + background-color: ${({ theme, $isOnline }) => ($isOnline ? theme.colors.online : theme.colors.black)}; `; export const UserInfo = styled.div` diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfileTab/components/DeleteAccountConfirmModal/index.tsx b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/components/DeleteAccountConfirmModal/index.tsx new file mode 100644 index 00000000..f320cbe3 --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/components/DeleteAccountConfirmModal/index.tsx @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom'; + +import { deleteAccount } from '@/api/users'; +import Modal from '@/components/common/Modal'; +import useModalStore from '@/stores/modalStore'; +import { useUserInfoStore } from '@/stores/userInfo'; + +import * as S from './styles'; + +const DeleteAccountConfirmModal = () => { + const navigate = useNavigate(); + const { closeAllModal } = useModalStore(); + const { userInfo } = useUserInfoStore(); + if (!userInfo) return null; + + const handleDeleteAccount = async () => { + try { + await deleteAccount({ userId: userInfo.userId }); + closeAllModal(); + navigate('/login', { replace: true }); + } catch (error) { + console.error('회원 탈퇴 실패', error); + } + }; + + return ( + + + 정말 계정을 탈퇴하시겠어요? + + + 취소 + 탈퇴하기 + + + ); +}; + +export default DeleteAccountConfirmModal; diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfileTab/components/DeleteAccountConfirmModal/styles.ts b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/components/DeleteAccountConfirmModal/styles.ts new file mode 100644 index 00000000..61f5f8f2 --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/components/DeleteAccountConfirmModal/styles.ts @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +import { BodyMediumText } from '@/styles/Typography'; + +export const ConfirmText = styled(BodyMediumText)` + color: ${({ theme }) => theme.colors.white}; +`; + +export const ModalButtonContainer = styled.div` + display: flex; + gap: 1.6rem; + align-items: center; + justify-content: flex-end; + + height: 7rem; + padding: 1.6rem; + + background-color: ${({ theme }) => theme.colors.dark[600]}; +`; + +export const CancelButton = styled.button` + height: 3.8rem; + padding: 0.2rem 1.6rem; + color: ${({ theme }) => theme.colors.white}; + + &:hover { + text-decoration: underline; + } +`; + +export const DeleteButton = styled.button` + height: 3.8rem; + padding: 0.2rem 1.6rem; + border-radius: 0.4rem; + + color: ${({ theme }) => theme.colors.white}; + + background-color: ${({ theme }) => theme.colors.red}; + + &:hover { + opacity: 0.8; + } +`; diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfileTab/hooks/useUserProfileTabElements.ts b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/hooks/useUserProfileTabElements.tsx similarity index 56% rename from src/frontend/src/pages/FriendsPage/components/UserProfileTab/hooks/useUserProfileTabElements.ts rename to src/frontend/src/pages/FriendsPage/components/UserProfileTab/hooks/useUserProfileTabElements.tsx index 29489ab0..672ad25e 100644 --- a/src/frontend/src/pages/FriendsPage/components/UserProfileTab/hooks/useUserProfileTabElements.ts +++ b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/hooks/useUserProfileTabElements.tsx @@ -1,5 +1,10 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; +import useModalStore from '@/stores/modalStore'; + +import DeleteAccountConfirmModal from '../components/DeleteAccountConfirmModal'; + interface MenuItem { elementId: string; content: string; @@ -7,7 +12,10 @@ interface MenuItem { } const useUserProfileTabElements = () => { + const queryClient = useQueryClient(); const navigate = useNavigate(); + const { openModal } = useModalStore(); + const handleModifyProfile = () => { // 추후 정보 수정 모달 구현 console.log('사용자 정보 수정 메뉴 클릭'); @@ -15,9 +23,16 @@ const useUserProfileTabElements = () => { const handleLogout = () => { localStorage.removeItem('access_token'); + // staleTime이 설정된 쿼리 삭제 + queryClient.removeQueries({ queryKey: ['userInfo'] }); + queryClient.removeQueries({ queryKey: ['friendsList'] }); navigate('/login', { replace: true }); }; + const handleDeleteAccount = () => { + openModal('withFooter', ); + }; + const userProfileTabElements: MenuItem[] = [ { elementId: 'modifyUserProfile', @@ -29,6 +44,11 @@ const useUserProfileTabElements = () => { content: '로그아웃', handleClick: handleLogout, }, + { + elementId: 'deleteAccount', + content: '회원 탈퇴', + handleClick: handleDeleteAccount, + }, ]; return { userProfileTabElements }; diff --git a/src/frontend/src/pages/FriendsPage/components/UserProfileTab/index.tsx b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/index.tsx index 464751d4..6bc8a231 100644 --- a/src/frontend/src/pages/FriendsPage/components/UserProfileTab/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/UserProfileTab/index.tsx @@ -1,5 +1,6 @@ import useUserProfileTabElements from './hooks/useUserProfileTabElements'; import * as S from './styles'; + const UserProfileTab = () => { const { userProfileTabElements } = useUserProfileTabElements(); diff --git a/src/frontend/src/stores/channelInfo.ts b/src/frontend/src/stores/channelInfo.ts index 3ac5c3fc..111579aa 100644 --- a/src/frontend/src/stores/channelInfo.ts +++ b/src/frontend/src/stores/channelInfo.ts @@ -18,8 +18,8 @@ interface DMChannelInfo { interface ChannelState { selectedChannel: GuildChannelInfo | null; selectedDMChannel: DMChannelInfo | null; - setSelectedChannel: (channel: GuildChannelInfo) => void; - setSelectedDMChannel: (channel: DMChannelInfo) => void; + setSelectedChannel: (channel: GuildChannelInfo | null) => void; + setSelectedDMChannel: (channel: DMChannelInfo | null) => void; } export const useChannelInfoStore = create()( diff --git a/src/frontend/src/stores/userInfo.ts b/src/frontend/src/stores/userInfo.ts index a3f14304..cba2c1bb 100644 --- a/src/frontend/src/stores/userInfo.ts +++ b/src/frontend/src/stores/userInfo.ts @@ -11,7 +11,7 @@ interface UserState { clearUserInfo: () => void; } -export const useChannelInfoStore = create()( +export const useUserInfoStore = create()( persist( (set) => ({ userInfo: null, diff --git a/src/frontend/src/styles/theme.ts b/src/frontend/src/styles/theme.ts index 82de024e..7d62e7ef 100644 --- a/src/frontend/src/styles/theme.ts +++ b/src/frontend/src/styles/theme.ts @@ -16,6 +16,7 @@ const colors = { }, red: '#FF595E', green: '#248045', + online: '#23A55A', blue: '#5765F2', link: '#069BE3', }; diff --git a/src/frontend/src/types/directMessages.ts b/src/frontend/src/types/directMessages.ts new file mode 100644 index 00000000..e8db34c7 --- /dev/null +++ b/src/frontend/src/types/directMessages.ts @@ -0,0 +1,35 @@ +interface DirectMessageMemberInfo { + userId: string; + name: string; + nickname: string; + profileImageUrl: string; + email: string; + birth: string; +} + +interface DirectMessageInfo { + directId: string; + members: { + responses: DirectMessageMemberInfo[]; + }; +} + +export interface GetDirectMessagesResponse { + httpStatus: number; + message: string; + time: Date; + result: { + directResponses: DirectMessageInfo[]; + }; +} + +export interface PostDirectMessageRequest { + memberIds: string[]; +} + +export interface PostDirectMessageResponse { + httpStatus: number; + message: string; + time: Date; + result: DirectMessageInfo; +} diff --git a/src/frontend/src/types/friends.ts b/src/frontend/src/types/friends.ts new file mode 100644 index 00000000..bb3025cc --- /dev/null +++ b/src/frontend/src/types/friends.ts @@ -0,0 +1,65 @@ +// result가 없는 기본 응답 +export interface FriendDefaultResponse { + httpStatus: number; + message: string; + time: Date; + result: null; +} + +export type DeleteFriendResponse = FriendDefaultResponse; + +export interface FriendInfo { + userId: string; + name: string; + nickname: string; + profileImageUrl: string; + email: string; + birth: string; +} + +export interface GetFriendInfoResponse { + httpStatus: number; + message: string; + time: Date; + result: FriendInfo; +} + +export interface GetSentRequestsResponse { + httpStatus: number; + message: string; + time: Date; + result: { + friends: FriendInfo[]; + status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; + }; +} + +export type GetReceivedRequestsResponse = GetSentRequestsResponse; + +export interface GetFriendsListResponse { + httpStatus: number; + message: string; + time: Date; + result: { + friends: FriendInfo[]; + }; +} + +export interface FriendRequest { + id: string; + userId1: string; + userId2: string; + requestedBy: string; + status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; +} + +export interface PostRequestResponse { + httpStatus: number; + message: string; + time: Date; + result: FriendRequest; +} + +export type PostRejectResponse = PostRequestResponse; + +export type PostAcceptResponse = PostRequestResponse; diff --git a/src/frontend/src/types/users.ts b/src/frontend/src/types/users.ts index e3f4cb28..93b91279 100644 --- a/src/frontend/src/types/users.ts +++ b/src/frontend/src/types/users.ts @@ -6,6 +6,8 @@ export interface UserDefaultResponse { result: null; } +export type DeleteAccountResponse = UserDefaultResponse; + export interface PostLoginRequest { email: string; password: string; @@ -46,6 +48,20 @@ export interface PostEmailDuplicateResponse { }; } +export interface GetUserInfoResponse { + httpStatus: number; + message: string; + time: Date; + result: { + userId: string; + name: string; + nickname: string; + profileImageUrl: string; + email: string; + birth: string; + }; +} + export interface PatchUserInfoRequest { name: string; nickname: string; diff --git a/src/frontend/src/utils/axios.ts b/src/frontend/src/utils/axios.ts index 498d2224..72f4d98b 100644 --- a/src/frontend/src/utils/axios.ts +++ b/src/frontend/src/utils/axios.ts @@ -11,7 +11,7 @@ export const tokenAxios = axios.create({ }); tokenAxios.interceptors.request.use((config) => { - const token = import.meta.env.VITE_TOKEN; + const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`;