From 7ca37c5fd9594be7169b93e7fce8d54d9eeaa022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=ED=98=9C=EC=84=A0?= Date: Thu, 22 Feb 2024 22:21:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B9=9C=EA=B5=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99=20=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: mypage> friendslist -> friendship 네이밍 변경 * chore: buildQuery function 추가 * feat: useBaseInfiniteQuery hook 생성 * feat: 친구 목록 조회 API 연동 * feat: useObserver hook 추가, 무한 스크롤 적용 * docs: pr template 수정, deploy.sh 공백 제거 * feat: returnProfileImg function 생성 * feat: next.config.js image url setting * fix: 친구 목록 프로필 이미지 적용, isFetchingNextPage 에 따라 로딩스피너 추가 --- .github/pull_request_template.md | 3 +- deploy.sh | 4 +- next.config.js | 6 ++ src/components/organisms/FriendListItem.tsx | 27 +++++-- .../templates/FriendListTemplate.tsx | 80 ++++++++++++------- src/hooks/queries/friendship/index.ts | 1 + .../queries/friendship/useGetFriendships.ts | 14 ++++ src/hooks/queries/queryKeys.ts | 3 + src/hooks/queries/useBaseInfiniteQuery.ts | 50 ++++++++++++ src/hooks/useObserver.ts | 35 ++++++++ .../{friendslist => friendship}/index.tsx | 14 ++-- src/pages/mypage/index.tsx | 2 +- src/types/common/index.d.ts | 2 + src/types/friendship/index.d.ts | 7 ++ src/types/query/index.d.ts | 33 ++++++++ src/utils/buildQuery.ts | 8 ++ src/utils/returnProfileImg.ts | 16 ++++ 17 files changed, 255 insertions(+), 50 deletions(-) create mode 100644 src/hooks/queries/friendship/index.ts create mode 100644 src/hooks/queries/friendship/useGetFriendships.ts create mode 100644 src/hooks/queries/useBaseInfiniteQuery.ts create mode 100644 src/hooks/useObserver.ts rename src/pages/mypage/{friendslist => friendship}/index.tsx (87%) create mode 100644 src/types/friendship/index.d.ts create mode 100644 src/types/query/index.d.ts create mode 100644 src/utils/buildQuery.ts create mode 100644 src/utils/returnProfileImg.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c316a5a..d8bf6aa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,8 @@ ## 요구사항 - 이슈번호: [Jira 이슈번호](Jira 이슈링크) ## 작업내용 - - ## 테스트 결과 또는 방법 +- diff --git a/deploy.sh b/deploy.sh index 261b8e7..214f309 100644 --- a/deploy.sh +++ b/deploy.sh @@ -3,6 +3,4 @@ echo "> FE 배포" cd /home/ubuntu/fridge-link-deploy -pm2 restart all - - +pm2 restart all \ No newline at end of file diff --git a/next.config.js b/next.config.js index 408b379..04ccec9 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,12 @@ const nextConfig = { reactStrictMode: true, images: { domains: ['mara-s3bucket.s3.ap-northeast-2.amazonaws.com'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'fridge-link-img.s3.ap-northeast-2.amazonaws.com', + } + ] }, webpack: (config) => { return { diff --git a/src/components/organisms/FriendListItem.tsx b/src/components/organisms/FriendListItem.tsx index e34ebcd..0411bf9 100644 --- a/src/components/organisms/FriendListItem.tsx +++ b/src/components/organisms/FriendListItem.tsx @@ -1,17 +1,29 @@ 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'; -const FriendListItem: React.FC<{ name: string; count: number }> = ({ - name, - count, -}) => { +const FriendListItem: React.FC<{ + name: string; + count: number; + profileEnum: ProfileEnum; +}> = ({ name, count, profileEnum }) => { return (
-
-
+
+ 친구 프로필

{name}

-

{count}

+

+ 냉장고 식자재 목록 {count}개 +

= ({ height={16} fill="#CCCFD7" transform="rotate(180)" + className="z-0" />
); diff --git a/src/components/templates/FriendListTemplate.tsx b/src/components/templates/FriendListTemplate.tsx index e6334c6..716734e 100644 --- a/src/components/templates/FriendListTemplate.tsx +++ b/src/components/templates/FriendListTemplate.tsx @@ -6,53 +6,73 @@ import { useDisclosure, } from '@chakra-ui/react'; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { type SortLabel } from '@/types/common'; import { RadioButtonField, SortButton } from '../atoms'; import { FriendListItem } from '../organisms'; +import { useGetFriendships } from '@/hooks/queries/friendship'; +import type { FriendshipData, FriendshipSortType } from '@/types/friendship'; +import { SuspenseFallback } from '.'; +import { useObserver } from '@/hooks/useObserver'; const SORT_TYPES: SortLabel[] = [ - { label: '이름순', value: 'name' }, - { label: '등록순', value: 'latest' }, + { label: '이름순', value: 'nickname' }, + { label: '등록순', value: 'createdAt' }, ]; -const MOCK_DATA = { - count: 13, - data: [ - { - id: 1, - name: '김지수', - ingrediendNum: 14, - }, - { - id: 2, - name: '김지수', - ingrediendNum: 14, - }, - ], -}; + const FriendListTemplate: React.FC = () => { + const bottom = useRef(null); const { isOpen, onOpen, onClose } = useDisclosure(); const [curSortType, setCurSortType] = useState(SORT_TYPES[0]); + const { + data: friendsData, + fetchNextPage: friendsNextPage, + isFetchingNextPage: isFetchingfriendsNextPage, + } = useGetFriendships({ + sort: curSortType.value as FriendshipSortType, + }); + + const onIntersect: IntersectionObserverCallback = ([entry]) => { + if (entry.isIntersecting) { + void friendsNextPage(); + } + }; + + useObserver({ + target: bottom, + onIntersect, + }); + + if (!friendsData?.pages[0].content) { + return ; + } return ( - <> -
+
+
-

총 {MOCK_DATA.count}명

+

+ 총 {friendsData?.pages[0].totalElements}명 +

-
- {MOCK_DATA.data.map((ele) => ( - - ))} +
+ {friendsData.pages.map((page) => + page.content.map((ele: FriendshipData) => ( + + )), + )}
+ {isFetchingfriendsNextPage ? :
} { - +
); }; diff --git a/src/hooks/queries/friendship/index.ts b/src/hooks/queries/friendship/index.ts new file mode 100644 index 0000000..035f555 --- /dev/null +++ b/src/hooks/queries/friendship/index.ts @@ -0,0 +1 @@ +export { default as useGetFriendships } from './useGetFriendships'; diff --git a/src/hooks/queries/friendship/useGetFriendships.ts b/src/hooks/queries/friendship/useGetFriendships.ts new file mode 100644 index 0000000..d3aa89e --- /dev/null +++ b/src/hooks/queries/friendship/useGetFriendships.ts @@ -0,0 +1,14 @@ +import type { FriendshipData, FriendshipSortType } from '@/types/friendship'; + +import { queryKeys } from '../queryKeys'; +import { useBaseInfiniteQuery } from '../useBaseInfiniteQuery'; + +const useGetFriendships = ({ sort }: { sort: FriendshipSortType }) => { + return useBaseInfiniteQuery({ + queryKey: queryKeys.FRIENDSHIPS(sort), + url: '/friendship', + sort, + }); +}; + +export default useGetFriendships; diff --git a/src/hooks/queries/queryKeys.ts b/src/hooks/queries/queryKeys.ts index 42585d8..e88a097 100644 --- a/src/hooks/queries/queryKeys.ts +++ b/src/hooks/queries/queryKeys.ts @@ -1,7 +1,10 @@ +import type { FriendshipSortType } from '@/types/friendship'; + export const queryKeys = { INGREDIENT: (id?: number) => (id ? ['ingredient', id] : ['ingredient']), KAKAO: () => ['kakao'], SHARES: () => ['shares'], + FRIENDSHIPS: (sort: FriendshipSortType) => ['friendship', sort], } as const; export type QueryKeys = (typeof queryKeys)[keyof typeof queryKeys]; diff --git a/src/hooks/queries/useBaseInfiniteQuery.ts b/src/hooks/queries/useBaseInfiniteQuery.ts new file mode 100644 index 0000000..6f9bf3d --- /dev/null +++ b/src/hooks/queries/useBaseInfiniteQuery.ts @@ -0,0 +1,50 @@ +import type { ApiResponseType, InfiniteQueryResult } from '@/types/query'; +import type { QueryFunctionContext, QueryKey } from '@tanstack/react-query'; + +import axiosInstance from '@/api/axiosInstance'; +import { buildQuery } from '@/utils/buildQuery'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +export const getNextOffset = (data: InfiniteQueryResult) => { + return data.last ? undefined : data.pageable.pageNumber + 1; +}; + +export const useBaseInfiniteQuery = ({ + queryKey, + url, + size, + sort, +}: { + queryKey: QueryKey; + url: string; + size?: number; + sort?: string; +}) => { + const INITIAL_PAGE_PARAM = 0; + const DEFAULT_SIZE = 10; + + const fetchData = async ( + context: QueryFunctionContext, + ) => { + const { pageParam = 0 } = context; + + const queryParamString = buildQuery({ + page: pageParam, + size: size ?? DEFAULT_SIZE, + sort, + }); + + const URL = `${url}?${queryParamString}`; + const response = + await axiosInstance.get>>(URL); + return response.data.data; + }; + + return useInfiniteQuery({ + queryKey, + queryFn: async (context: QueryFunctionContext) => + await fetchData(context), + initialPageParam: INITIAL_PAGE_PARAM, + getNextPageParam: (res) => getNextOffset(res), + }); +}; diff --git a/src/hooks/useObserver.ts b/src/hooks/useObserver.ts new file mode 100644 index 0000000..0264f25 --- /dev/null +++ b/src/hooks/useObserver.ts @@ -0,0 +1,35 @@ +import type { RefObject } from 'react'; +import { useEffect } from 'react'; + +export const useObserver = ({ + target, + onIntersect, + root = null, + rootMargin = '1px', + threshold = 0, +}: { + target: RefObject; + onIntersect: IntersectionObserverCallback; + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; +}) => { + useEffect(() => { + let observer: IntersectionObserver | undefined; + + if (target?.current !== null) { + observer = new IntersectionObserver(onIntersect, { + root, + rootMargin, + threshold, + }); + observer.observe(target.current); + } + + return () => { + if (typeof observer !== 'undefined') { + observer.disconnect(); + } + }; + }, [target, rootMargin, threshold, onIntersect, root]); +}; diff --git a/src/pages/mypage/friendslist/index.tsx b/src/pages/mypage/friendship/index.tsx similarity index 87% rename from src/pages/mypage/friendslist/index.tsx rename to src/pages/mypage/friendship/index.tsx index 38bc936..139aadf 100644 --- a/src/pages/mypage/friendslist/index.tsx +++ b/src/pages/mypage/friendship/index.tsx @@ -1,10 +1,10 @@ -import { SettingIcon } from '@/assets/icons'; -import { TabButton } from '@/components/atoms'; -import Header from '@/components/organisms/Header'; import { AddFriendTemplate, FriendListTemplate } from '@/components/templates'; -import { type TabLabel } from '@/types/common'; -import { type NextPage } from 'next'; +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'; const TABS: TabLabel[] = [ @@ -12,7 +12,7 @@ const TABS: TabLabel[] = [ { label: '친구 추가', value: 'add' }, ]; -const FriendsListPage: NextPage = () => { +const FriendShipPage: NextPage = () => { const [curTab, setCurTab] = useState(TABS[0]); return ( @@ -41,4 +41,4 @@ const FriendsListPage: NextPage = () => {
); }; -export default FriendsListPage; +export default FriendShipPage; diff --git a/src/pages/mypage/index.tsx b/src/pages/mypage/index.tsx index e4fcbe9..a44407e 100644 --- a/src/pages/mypage/index.tsx +++ b/src/pages/mypage/index.tsx @@ -31,7 +31,7 @@ const GENERAGE_NAV_LIST = [ { name: '친구', svgComponent: , - linkTo: '/mypage/friendslist', + linkTo: '/mypage/friendship', }, { name: '나눔 내역', svgComponent: , linkTo: '' }, ]; diff --git a/src/types/common/index.d.ts b/src/types/common/index.d.ts index 3c13eef..66633ba 100644 --- a/src/types/common/index.d.ts +++ b/src/types/common/index.d.ts @@ -14,3 +14,5 @@ export interface ResErrorType { error: string; path: string; } + +export type ProfileEnum = 'GREEN' | 'RED' | 'BLUE' | 'YELLOW'; diff --git a/src/types/friendship/index.d.ts b/src/types/friendship/index.d.ts new file mode 100644 index 0000000..da634f8 --- /dev/null +++ b/src/types/friendship/index.d.ts @@ -0,0 +1,7 @@ +export interface FriendshipData { + userId: number; + nickname: string; + ingredientCount: number; +} + +export type FriendshipSortType = 'nickname' | 'createdAt'; diff --git a/src/types/query/index.d.ts b/src/types/query/index.d.ts new file mode 100644 index 0000000..6e91493 --- /dev/null +++ b/src/types/query/index.d.ts @@ -0,0 +1,33 @@ +export interface InfiniteQueryResult { + totalPages: number; + totalElements: number; + size: number; + content: T; + number: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + numberOfElements: number; + pageable: { + offset: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + pageSize: number; + paged: boolean; + pageNumber: number; + unpaged: boolean; + }; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface ApiResponseType { + message: string; + data: T; +} diff --git a/src/utils/buildQuery.ts b/src/utils/buildQuery.ts new file mode 100644 index 0000000..f0971d4 --- /dev/null +++ b/src/utils/buildQuery.ts @@ -0,0 +1,8 @@ +export const buildQuery = (param: Record) => { + const queryParam = Object.fromEntries( + Object.entries(param).filter( + ([, value]) => value !== '' && value !== null && value !== undefined, + ), + ); + return new URLSearchParams(queryParam).toString(); +}; diff --git a/src/utils/returnProfileImg.ts b/src/utils/returnProfileImg.ts new file mode 100644 index 0000000..abd8d45 --- /dev/null +++ b/src/utils/returnProfileImg.ts @@ -0,0 +1,16 @@ +import type { ProfileEnum } from '@/types/common'; + +export const returnProfileImg = (imgEnum: ProfileEnum) => { + switch (imgEnum) { + case 'GREEN': + return 'https://fridge-link-img.s3.ap-northeast-2.amazonaws.com/profile_img_green.png'; + case 'RED': + return 'https://fridge-link-img.s3.ap-northeast-2.amazonaws.com/profile_img_red.png'; + case 'BLUE': + return 'https://fridge-link-img.s3.ap-northeast-2.amazonaws.com/profile_img_blue.png'; + case 'YELLOW': + return 'https://fridge-link-img.s3.ap-northeast-2.amazonaws.com/profile_img_yellow.png'; + default: + return 'https://fridge-link-img.s3.ap-northeast-2.amazonaws.com/profile_img_green.png'; + } +};