From f588dd2c5cc37c8fb09628cf4d794f2a71da1f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=ED=98=9C=EC=84=A0?= Date: Fri, 23 Feb 2024 23:09:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B9=9C=EA=B5=AC=20=EC=82=AD=EC=A0=9C,=20?= =?UTF-8?q?=EB=82=B4=20=EC=B4=88=EB=8C=80=EC=BD=94=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=20=EC=B9=9C=EA=B5=AC=20=EC=B6=94=EA=B0=80=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: eslint @typescript-eslint/no-floating-promises off * feat: CheckIcon 추가, CheckBox component 생성 * fix: Button component ButtonHTMLAttributes props추가 * chore: 친구 페이지 헤더 우측 icon button component 분리 * feat: 친구 제거 API 연동 * style: 친구 페이지 스타일 수정 * feat: 내 초대코드 조회 API 연동 * feat: WarningIcon 추가, WarningLine component 생성 * feat: 친구 추가 API 연동 * fix: build error 수정 * refactor: CheckBox refactoring * refactor: 친구추가 성공, 초대코드 복사 성공시 토스트 메세지 상수화 * refactor: 내 초대코드 조회 query useGetMyInviteCode function으로 분리 --- .eslintrc.js | 1 + src/assets/icons/CheckIcon.tsx | 9 ++ src/assets/icons/WarningIcon.tsx | 9 ++ src/assets/icons/index.ts | 2 + src/components/atoms/Button.tsx | 5 +- src/components/atoms/CheckBox.tsx | 18 +++ src/components/atoms/index.ts | 1 + .../FriendshipHeaderSettingButton.tsx | 14 +++ src/components/molecules/LabelRoundBox.tsx | 2 +- src/components/molecules/WarningLine.tsx | 13 +++ src/components/molecules/index.ts | 1 + src/components/organisms/FriendListItem.tsx | 39 ++++--- .../templates/AddFriendTemplate.tsx | 71 +++++++++--- .../templates/FriendListTemplate.tsx | 106 ++++++++++++++++-- src/hooks/queries/friendship/index.ts | 3 + .../queries/friendship/useAddFriendship.ts | 11 ++ .../queries/friendship/useDeleteFriendship.ts | 11 ++ .../queries/friendship/useGetMyInviteCode.ts | 17 +++ src/hooks/queries/queryKeys.ts | 3 + src/pages/mypage/friendship/index.tsx | 19 +++- 20 files changed, 308 insertions(+), 47 deletions(-) create mode 100644 src/assets/icons/CheckIcon.tsx create mode 100644 src/assets/icons/WarningIcon.tsx create mode 100644 src/components/atoms/CheckBox.tsx create mode 100644 src/components/molecules/FriendshipHeaderSettingButton.tsx create mode 100644 src/components/molecules/WarningLine.tsx create mode 100644 src/hooks/queries/friendship/useAddFriendship.ts create mode 100644 src/hooks/queries/friendship/useDeleteFriendship.ts create mode 100644 src/hooks/queries/friendship/useGetMyInviteCode.ts diff --git a/.eslintrc.js b/.eslintrc.js index fd9dec0..140164d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,5 +20,6 @@ module.exports = { '@typescript-eslint/strict-boolean-expressions': 'off', 'prettier/prettier': ['error', { endOfLine: 'auto' }], '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-floating-promises': 'off' }, }; diff --git a/src/assets/icons/CheckIcon.tsx b/src/assets/icons/CheckIcon.tsx new file mode 100644 index 0000000..d2a4c4b --- /dev/null +++ b/src/assets/icons/CheckIcon.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const CheckIcon: React.FC> = (props) => ( + + + +); +export default CheckIcon; + diff --git a/src/assets/icons/WarningIcon.tsx b/src/assets/icons/WarningIcon.tsx new file mode 100644 index 0000000..e0c14c1 --- /dev/null +++ b/src/assets/icons/WarningIcon.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const WarningIcon: React.FC> = (props) => ( + + + +); + +export default WarningIcon; \ No newline at end of file diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index c4b2dfe..ae1a479 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -27,3 +27,5 @@ export { default as DateIcon } from './DateIcon'; export { default as ClockIcon } from './ClockIcon'; export { default as CameraIcon } from './CameraIcon'; export { default as CircleCloseIcon } from './CircleCloseIcon'; +export { default as CheckIcon } from './CheckIcon'; +export { default as WarningIcon } from './WarningIcon'; diff --git a/src/components/atoms/Button.tsx b/src/components/atoms/Button.tsx index a728c37..76e4d15 100644 --- a/src/components/atoms/Button.tsx +++ b/src/components/atoms/Button.tsx @@ -6,11 +6,14 @@ interface ButtonProps { className?: string; } -const Button: React.FC = ({ text, className, onClick }) => { +const Button: React.FC< + ButtonProps & React.ButtonHTMLAttributes +> = ({ text, className, onClick, ...props }) => { return ( diff --git a/src/components/atoms/CheckBox.tsx b/src/components/atoms/CheckBox.tsx new file mode 100644 index 0000000..9f8e388 --- /dev/null +++ b/src/components/atoms/CheckBox.tsx @@ -0,0 +1,18 @@ +import { CheckIcon } from '@/assets/icons'; +import React from 'react'; + +const CheckBox: React.FC<{ active: boolean; onClick: () => void }> = ({ + active = false, + onClick, +}) => { + const commonStyle = + 'flex justify-center items-center w-[20px] h-[20px] rounded-full'; + const buttonClassName = `${commonStyle} ${active ? 'bg-primary2' : 'border border-gray5'}`; + return ( + + ); +}; + +export default CheckBox; diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts index 291e25c..7c06d92 100644 --- a/src/components/atoms/index.ts +++ b/src/components/atoms/index.ts @@ -17,3 +17,4 @@ export { default as Input } from './Input'; export { default as MiniButton } from './MiniButton'; export { default as ExclamationAlertSpan } from './ExclamationAlertSpan'; export { default as Lottie } from './Lottie'; +export { default as CheckBox } from './CheckBox'; diff --git a/src/components/molecules/FriendshipHeaderSettingButton.tsx b/src/components/molecules/FriendshipHeaderSettingButton.tsx new file mode 100644 index 0000000..42c49a7 --- /dev/null +++ b/src/components/molecules/FriendshipHeaderSettingButton.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { SettingIcon } from '@/assets/icons'; + +const FriendshipHeaderSettingButton: React.FC<{ onClick: () => void }> = ({ + onClick, +}) => { + return ( + + ); +}; + +export default FriendshipHeaderSettingButton; diff --git a/src/components/molecules/LabelRoundBox.tsx b/src/components/molecules/LabelRoundBox.tsx index 87aaf4e..f47406a 100644 --- a/src/components/molecules/LabelRoundBox.tsx +++ b/src/components/molecules/LabelRoundBox.tsx @@ -7,7 +7,7 @@ const LabelRoundBox: React.FC<{ return (

{label}

-
{content}
+ {content}
); }; diff --git a/src/components/molecules/WarningLine.tsx b/src/components/molecules/WarningLine.tsx new file mode 100644 index 0000000..945ec98 --- /dev/null +++ b/src/components/molecules/WarningLine.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { WarningIcon } from '@/assets/icons'; + +const WarningLine: React.FC<{ text: string }> = ({ text }) => { + return ( +
+ +

{text}

+
+ ); +}; + +export default WarningLine; diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 8d972b6..99e97e5 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -12,3 +12,4 @@ export { default as NavWhiteBoxItem } from './NavWhiteBoxItem'; export { default as VerticalLabelValue } from './VerticalLabelValue'; export { default as ShareInfoRowItem } from './ShareInfoRowItem'; export { default as LabelRoundBox } from './LabelRoundBox'; +export { default as WarningLine } from './WarningLine'; diff --git a/src/components/organisms/FriendListItem.tsx b/src/components/organisms/FriendListItem.tsx index 0411bf9..852733c 100644 --- a/src/components/organisms/FriendListItem.tsx +++ b/src/components/organisms/FriendListItem.tsx @@ -1,38 +1,47 @@ import { AngleIcon } from '@/assets/icons'; -import { type ProfileEnum } from '@/types/common'; import { returnProfileImg } from '@/utils/returnProfileImg'; import Image from 'next/image'; import React from 'react'; +import { CheckBox } from '@/components/atoms'; +import { type FriendshipData } from '@/types/friendship'; const FriendListItem: React.FC<{ - name: string; - count: number; - profileEnum: ProfileEnum; -}> = ({ name, count, profileEnum }) => { + data: FriendshipData; + possibleDelete: boolean; + onClick: () => void; + active: boolean; +}> = ({ data, possibleDelete, onClick, active }) => { return (
+ {/* TODO profile img ENUM res 데이터로 교체 */} 친구 프로필
-

{name}

+

+ {data.nickname} +

- 냉장고 식자재 목록 {count}개 + 냉장고 식자재 목록 {data.ingredientCount}개

- + {possibleDelete ? ( + + ) : ( + + )}
); }; diff --git a/src/components/templates/AddFriendTemplate.tsx b/src/components/templates/AddFriendTemplate.tsx index accb0a6..d4466fc 100644 --- a/src/components/templates/AddFriendTemplate.tsx +++ b/src/components/templates/AddFriendTemplate.tsx @@ -1,47 +1,82 @@ -import React, { useEffect, useState } from 'react'; +import { LabelRoundBox, WarningLine } from '@/components/molecules'; +import React, { useState } from 'react'; +import { + useAddFriendship, + useGetMyInviteCode, +} from '@/hooks/queries/friendship'; -import { BulletNoticeBox } from '../organisms'; -import { LabelRoundBox } from '../molecules'; +import { BulletNoticeBox } from '@/components/organisms'; import { MiniButton } from '@/components/atoms'; +import useToast from '@/hooks/useToast'; -const MY_INVITATION_CODE = 'AB12CD3EF'; +const FRIEND_ADD_SUCCESS_MESSAGE = '친구 추가가 완료되었습니다.'; +const CODE_COPY_SUCCESS_MESSAGE = '초대 코드가 복사되었습니다.'; const AddFriendTemplate: React.FC = () => { - const [myCode, setMyCode] = useState(''); + const [friendInviteCode, setFriendInviteCode] = useState(''); + const [warningVisible, setWarningVisible] = useState(false); + const { showToast } = useToast(); + const addFriendship = useAddFriendship({ + onSuccess: () => { + showToast(FRIEND_ADD_SUCCESS_MESSAGE, 'success'); + }, + }); const onCopy: () => void = () => { navigator.clipboard - .writeText(myCode) - .then(() => null) + .writeText(myInviteCode ?? '') + .then(() => { + showToast(CODE_COPY_SUCCESS_MESSAGE, 'success'); + }) .catch(() => null); }; - useEffect(() => { - setMyCode(MY_INVITATION_CODE); - }, []); + const onAddFriend = () => { + if (friendInviteCode.length < 9) { + setWarningVisible(true); + } else { + addFriendship.mutate({ inviteCode: friendInviteCode }); + } + }; + + const { inviteCode: myInviteCode } = useGetMyInviteCode(); return (
+
- {myCode} + {myInviteCode ?? ''} - +
} /> - - +
+ { + setFriendInviteCode(e.target.value); + }} + maxLength={10} + /> + +
+ {warningVisible ? ( + + ) : null} } /> diff --git a/src/components/templates/FriendListTemplate.tsx b/src/components/templates/FriendListTemplate.tsx index 716734e..82a3bf2 100644 --- a/src/components/templates/FriendListTemplate.tsx +++ b/src/components/templates/FriendListTemplate.tsx @@ -6,11 +6,14 @@ import { useDisclosure, } from '@chakra-ui/react'; -import React, { useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { type SortLabel } from '@/types/common'; -import { RadioButtonField, SortButton } from '../atoms'; +import { Button, RadioButtonField, SortButton } from '@/components/atoms'; import { FriendListItem } from '../organisms'; -import { useGetFriendships } from '@/hooks/queries/friendship'; +import { + useDeleteFriendship, + useGetFriendships, +} from '@/hooks/queries/friendship'; import type { FriendshipData, FriendshipSortType } from '@/types/friendship'; import { SuspenseFallback } from '.'; import { useObserver } from '@/hooks/useObserver'; @@ -20,29 +23,69 @@ const SORT_TYPES: SortLabel[] = [ { label: '등록순', value: 'createdAt' }, ]; -const FriendListTemplate: React.FC = () => { +const FriendListTemplate: React.FC<{ possibleDelete: boolean }> = ({ + possibleDelete, +}) => { const bottom = useRef(null); const { isOpen, onOpen, onClose } = useDisclosure(); + const { + isOpen: deleteIsOpen, + onOpen: deleteOnOpen, + onClose: deleteOnClose, + } = useDisclosure(); const [curSortType, setCurSortType] = useState(SORT_TYPES[0]); + const [selectedFriendIds, setSelectedFriendIds] = useState([]); const { data: friendsData, fetchNextPage: friendsNextPage, isFetchingNextPage: isFetchingfriendsNextPage, + refetch: friendsRefetch, } = useGetFriendships({ sort: curSortType.value as FriendshipSortType, }); + const deleteFriendship = useDeleteFriendship({ + onSuccess: () => { + deleteOnClose(); + friendsRefetch(); + }, + }); + const onIntersect: IntersectionObserverCallback = ([entry]) => { if (entry.isIntersecting) { void friendsNextPage(); } }; + const onClickDeleteFriends = useCallback(() => { + const friendIds: Array<{ friendId: number }> = []; + selectedFriendIds.forEach((ele) => friendIds.push({ friendId: ele })); + deleteFriendship.mutate(friendIds); + }, [selectedFriendIds]); + + const selectFriend = useCallback( + (id: number) => { + const tempSelectedFriendIds = selectedFriendIds.slice(); + const idx = tempSelectedFriendIds.indexOf(id); + if (idx === -1) { + tempSelectedFriendIds.push(id); + } else { + tempSelectedFriendIds.splice(idx, 1); + } + setSelectedFriendIds(tempSelectedFriendIds); + }, + [selectedFriendIds], + ); + useObserver({ target: bottom, onIntersect, }); + useEffect(() => { + setSelectedFriendIds([]); + }, [possibleDelete]); + if (!friendsData?.pages[0].content) { return ; } @@ -64,16 +107,63 @@ const FriendListTemplate: React.FC = () => { page.content.map((ele: FriendshipData) => ( { + selectFriend(ele.userId); + }} + active={selectedFriendIds.includes(ele.userId)} /> )), )}
+ {isFetchingfriendsNextPage ? :
} +
+
+ + + + + +

친구삭제

+

+ 삭제하기 버튼을 누르면 친구 목록에서 삭제됩니다. +

+
+
+
+
+
+ void }) => { + return useBaseMutation<{ inviteCode: string }>( + queryKeys.ADD_FRIENDSHIP(), + `/friendship`, + onSuccess, + ); +}; +export default useAddFriendship; diff --git a/src/hooks/queries/friendship/useDeleteFriendship.ts b/src/hooks/queries/friendship/useDeleteFriendship.ts new file mode 100644 index 0000000..6eeea94 --- /dev/null +++ b/src/hooks/queries/friendship/useDeleteFriendship.ts @@ -0,0 +1,11 @@ +import { queryKeys } from '../queryKeys'; +import { useBaseMutation } from '../useBaseMutation'; + +const useDeleteFriendship = ({ onSuccess }: { onSuccess: () => void }) => { + return useBaseMutation>( + queryKeys.DELETE_FRIENDSHIP(), + `/friendship/delete`, + onSuccess, + ); +}; +export default useDeleteFriendship; diff --git a/src/hooks/queries/friendship/useGetMyInviteCode.ts b/src/hooks/queries/friendship/useGetMyInviteCode.ts new file mode 100644 index 0000000..bf8c6ce --- /dev/null +++ b/src/hooks/queries/friendship/useGetMyInviteCode.ts @@ -0,0 +1,17 @@ +import { queryKeys } from '../queryKeys'; +import { useBaseQuery } from '../useBaseQuery'; + +const useGetMyInviteCode = () => { + const { data } = useBaseQuery<{ inviteCode: string }>( + queryKeys.MY_INVITE_CODE(), + '/users/me/invite-code', + ); + + if (!data?.data) { + return { inviteCode: '-' }; + } + + return data?.data; +}; + +export default useGetMyInviteCode; diff --git a/src/hooks/queries/queryKeys.ts b/src/hooks/queries/queryKeys.ts index 43571b3..7cb76f3 100644 --- a/src/hooks/queries/queryKeys.ts +++ b/src/hooks/queries/queryKeys.ts @@ -11,6 +11,9 @@ export const queryKeys = { SHARES: () => ['shares'], ME: () => ['my-info'], FRIENDSHIPS: (sort: FriendshipSortType) => ['friendship', sort], + DELETE_FRIENDSHIP: () => ['deleteFriendship'], + MY_INVITE_CODE: () => ['myInviteCode'], + ADD_FRIENDSHIP: () => ['addFriendship'], } as const; export type QueryKeys = (typeof queryKeys)[keyof typeof queryKeys]; diff --git a/src/pages/mypage/friendship/index.tsx b/src/pages/mypage/friendship/index.tsx index 139aadf..d5963bb 100644 --- a/src/pages/mypage/friendship/index.tsx +++ b/src/pages/mypage/friendship/index.tsx @@ -1,8 +1,8 @@ import { AddFriendTemplate, FriendListTemplate } from '@/components/templates'; +import FriendshipHeaderSettingButton from '@/components/molecules/FriendshipHeaderSettingButton'; import Header from '@/components/organisms/Header'; import type { NextPage } from 'next'; -import { SettingIcon } from '@/assets/icons'; import { TabButton } from '@/components/atoms'; import type { TabLabel } from '@/types/common'; import { useState } from 'react'; @@ -14,15 +14,22 @@ const TABS: TabLabel[] = [ const FriendShipPage: NextPage = () => { const [curTab, setCurTab] = useState(TABS[0]); + const [possibleDelete, setPossibleDelete] = useState(false); return (
} + headerRight={ + { + setPossibleDelete(!possibleDelete); + }} + /> + } backgroundColor="white" /> -
+
{TABS.map((ele: TabLabel) => ( {
- {curTab.value === 'list' ? : } + {curTab.value === 'list' ? ( + + ) : ( + + )}
); };