diff --git a/app/api/member/search/route.ts b/app/api/member/search/route.ts new file mode 100644 index 00000000..4e6f5d25 --- /dev/null +++ b/app/api/member/search/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { fetchData } from '@/apis/fetch-data'; +import { ProfileSearch } from '@/features/profile-search'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const cursorId = searchParams.get('cursorId') ?? ''; + const nameQuery = searchParams.get('nameQuery') ?? ''; + + const data = await fetchData( + `/member/search?nameQuery=${nameQuery}&cursorId=${cursorId}`, + 'GET', + ); + + return NextResponse.json(data); +} diff --git a/app/profile/search/page.tsx b/app/profile/search/page.tsx new file mode 100644 index 00000000..ecaf2a0b --- /dev/null +++ b/app/profile/search/page.tsx @@ -0,0 +1,55 @@ +import dynamic from 'next/dynamic'; + +import { LeftArrowIcon } from '@/components/atoms'; +import { HeaderBar } from '@/components/molecules'; + +const DynamicBackButton = dynamic( + () => import('@/components/molecules').then(({ BackButton }) => BackButton), + { + ssr: false, + loading: () => , + }, +); + +const DynamicSearchBarSection = dynamic( + () => + import('@/features/profile-search').then( + ({ SearchBarSection }) => SearchBarSection, + ), + { + ssr: false, + }, +); + +const DynamicSearchResultSection = dynamic( + () => + import('@/features/profile-search').then( + ({ SearchResultSection }) => SearchResultSection, + ), + { + ssr: false, + }, +); + +export default function ProfileSearch({ + searchParams, +}: { + searchParams: { keyword: string }; +}) { + const { keyword = '' } = searchParams; + + return ( + <> + + + + + 친구 찾기 + +
+ + +
+ + ); +} diff --git a/components/molecules/index.ts b/components/molecules/index.ts index 5c483c5f..cf445732 100644 --- a/components/molecules/index.ts +++ b/components/molecules/index.ts @@ -5,7 +5,8 @@ export * from './header-bar'; export * from './infinite-scroller'; export * from './modal'; export * from './page-modal'; -export * from './profile-list-item'; +export * from './profile-image'; +export * from './profile-list'; export * from './record-mark'; export * from './search-bar'; export * from './tab'; diff --git a/components/molecules/profile-image/index.ts b/components/molecules/profile-image/index.ts new file mode 100644 index 00000000..44545fa9 --- /dev/null +++ b/components/molecules/profile-image/index.ts @@ -0,0 +1 @@ +export * from './profile-image'; diff --git a/components/molecules/profile-image/profile-image.tsx b/components/molecules/profile-image/profile-image.tsx new file mode 100644 index 00000000..a179520a --- /dev/null +++ b/components/molecules/profile-image/profile-image.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { ImageProps } from 'next/image'; +import { useMemo } from 'react'; + +import { + defaultProfileImages, + ProfileIndexType, +} from '@/public/images/default-profile'; + +import { Image } from '../../atoms'; + +export const ProfileImage = ({ + src, + alt = 'profile image', + ...props +}: ImageProps) => { + const imageSrc = useMemo(() => { + const profileImage = defaultProfileImages[Number(src) as ProfileIndexType]; + return profileImage ?? src; + }, [src]); + + return {alt}; +}; diff --git a/components/molecules/profile-list-item/index.ts b/components/molecules/profile-list/index.ts similarity index 53% rename from components/molecules/profile-list-item/index.ts rename to components/molecules/profile-list/index.ts index 232a412e..a764c7f8 100644 --- a/components/molecules/profile-list-item/index.ts +++ b/components/molecules/profile-list/index.ts @@ -1 +1,2 @@ +export * from './profile-list'; export * from './profile-list-item'; diff --git a/components/molecules/profile-list-item/profile-list-item.tsx b/components/molecules/profile-list/profile-list-item.tsx similarity index 82% rename from components/molecules/profile-list-item/profile-list-item.tsx rename to components/molecules/profile-list/profile-list-item.tsx index 275e91f9..a9ba371c 100644 --- a/components/molecules/profile-list-item/profile-list-item.tsx +++ b/components/molecules/profile-list/profile-list-item.tsx @@ -1,18 +1,20 @@ import Link from 'next/link'; -import { Button, Image } from '@/components/atoms'; -import { ProfileFollowContent } from '@/features/follow'; +import { Button } from '@/components/atoms'; import { css } from '@/styled-system/css'; import { flex } from '@/styled-system/patterns'; +import { MemberProfile } from '@/types'; + +import { ProfileImage } from '../profile-image'; type FollowListItem = { isFollow: boolean; onClick?: () => void; onClickFollow?: () => void; -} & ProfileFollowContent; +} & MemberProfile; export const ProfileListItem = ({ memberId, - name, + nickname, introduction, profileImageUrl, isFollow, @@ -21,8 +23,8 @@ export const ProfileListItem = ({
- profile image
-

{name}

-

{introduction}

+

{nickname}

+ {introduction &&

{introduction}

}
diff --git a/features/follow/components/follow-virtual-list.tsx b/components/molecules/profile-list/profile-list.tsx similarity index 70% rename from features/follow/components/follow-virtual-list.tsx rename to components/molecules/profile-list/profile-list.tsx index a80255b6..bfe282c7 100644 --- a/features/follow/components/follow-virtual-list.tsx +++ b/components/molecules/profile-list/profile-list.tsx @@ -1,18 +1,17 @@ +'use client'; + import React from 'react'; import { Virtuoso } from 'react-virtuoso'; -import { ProfileListItem } from '@/components/molecules'; +import { MemberProfile } from '@/types'; -import { ProfileFollowContent } from '../types'; +import { ProfileListItem } from './profile-list-item'; -type FollowVirtualList = { - data: ProfileFollowContent[]; +type ProfileList = { + data: MemberProfile[]; fetchNextData: () => void; }; -export const FollowVirtualList = ({ - data, - fetchNextData, -}: FollowVirtualList) => { +export const ProfileList = ({ data, fetchNextData }: ProfileList) => { const handleRangeChanged = (range: { endIndex: number }) => { const currentContentsLastIndex = data.length - 1; if (range.endIndex >= currentContentsLastIndex - 3) { diff --git a/features/follow/components/index.ts b/features/follow/components/index.ts deleted file mode 100644 index 49cad4d9..00000000 --- a/features/follow/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './follow-virtual-list'; diff --git a/features/follow/sections/follower-section.tsx b/features/follow/sections/follower-section.tsx index 48416091..9f8b9214 100644 --- a/features/follow/sections/follower-section.tsx +++ b/features/follow/sections/follower-section.tsx @@ -1,7 +1,8 @@ 'use client'; +import { ProfileList } from '@/components/molecules'; + import { useFollowerList } from '../apis'; -import { FollowVirtualList } from '../components'; export const FollowerSection = ({ id }: { id: number }) => { const { flattenData, hasNextPage, isFetchingNextPage, fetchNextPage } = @@ -13,5 +14,5 @@ export const FollowerSection = ({ id }: { id: number }) => { } }; - return ; + return ; }; diff --git a/features/follow/sections/following-section.tsx b/features/follow/sections/following-section.tsx index d588b31b..d4243bb4 100644 --- a/features/follow/sections/following-section.tsx +++ b/features/follow/sections/following-section.tsx @@ -1,7 +1,8 @@ 'use client'; +import { ProfileList } from '@/components/molecules'; + import { useFollowingList } from '../apis'; -import { FollowVirtualList } from '../components'; export const FollowingSection = ({ id }: { id: number }) => { const { flattenData, hasNextPage, isFetchingNextPage, fetchNextPage } = @@ -13,5 +14,5 @@ export const FollowingSection = ({ id }: { id: number }) => { } }; - return ; + return ; }; diff --git a/features/follow/types/index.ts b/features/follow/types/index.ts index 231948d5..bb8dfe1d 100644 --- a/features/follow/types/index.ts +++ b/features/follow/types/index.ts @@ -1,16 +1,10 @@ import { Response } from '@/apis'; +import { MemberProfile } from '@/types'; export type FollowTab = 'follow' | 'following'; -export type ProfileFollowContent = { - memberId: number; - name: string; - profileImageUrl: string; - introduction: string; -}; - export type ProfileFollow = Response<{ - contents: ProfileFollowContent[]; + contents: MemberProfile[]; cursorId: number; hasNext: boolean; }>; diff --git a/features/profile-search/apis/index.ts b/features/profile-search/apis/index.ts new file mode 100644 index 00000000..16495410 --- /dev/null +++ b/features/profile-search/apis/index.ts @@ -0,0 +1 @@ +export * from './use-profile-search'; diff --git a/features/profile-search/apis/use-profile-search.tsx b/features/profile-search/apis/use-profile-search.tsx new file mode 100644 index 00000000..92b2cd5d --- /dev/null +++ b/features/profile-search/apis/use-profile-search.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { ProfileSearch } from '../types'; + +const fetchProfile = async (nameQuery: string, cursorId?: number) => { + const res = await fetch( + `/api/member/search?nameQuery=${nameQuery}&cursorId=${cursorId ?? ''}`, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + return res.json(); +}; + +export const useProfileSearch = (nameQuery: string) => { + const query = useInfiniteQuery({ + queryKey: ['useProfileSearch', nameQuery], + queryFn: ({ pageParam }) => fetchProfile(nameQuery, pageParam as number), + initialPageParam: undefined, + getNextPageParam: (lastPage) => + lastPage?.data?.hasNext ? lastPage?.data?.cursorId : undefined, + enabled: !!nameQuery?.length, + }); + + const flattenData = + query.data?.pages.flatMap(({ data }) => data?.memberInfoResponses ?? []) ?? + []; + + return { + ...query, + flattenData, + }; +}; diff --git a/features/profile-search/components/empty-keyword.tsx b/features/profile-search/components/empty-keyword.tsx new file mode 100644 index 00000000..933cef44 --- /dev/null +++ b/features/profile-search/components/empty-keyword.tsx @@ -0,0 +1,18 @@ +import { css } from '@/styled-system/css'; + +export const EmptyKeyword = () => { + return ( +
+ 친구를 팔로우하고 +
+ 서로의 기록에 응원을 보내보세요. +
+ ); +}; + +const containerStyle = css({ + m: '80px auto 0px', + textStyle: 'body2.normal', + color: 'text.alternative', + textAlign: 'center', +}); diff --git a/features/profile-search/components/empty-search-result.tsx b/features/profile-search/components/empty-search-result.tsx new file mode 100644 index 00000000..ffaf0618 --- /dev/null +++ b/features/profile-search/components/empty-search-result.tsx @@ -0,0 +1,31 @@ +import { css } from '@/styled-system/css'; +import { flex } from '@/styled-system/patterns'; + +export const EmptySearchResult = ({ keyword }: { keyword: string }) => { + return ( +
+

‘{keyword}‘ 유저가 없어요.

+

+ 마이페이지에서 내 프로필을 공유할 수 있어요 +

+
+ ); +}; + +const containerStyle = flex({ + direction: 'column', + gap: '4px', + m: '80px auto 0px', + align: 'center', +}); + +const titleStyle = css({ + textStyle: 'heading6', + fontWeight: 'medium', + color: 'text.normal', +}); + +const descriptionStyle = css({ + color: 'text.alternative', + fontWeight: 'regular', +}); diff --git a/features/profile-search/components/index.ts b/features/profile-search/components/index.ts new file mode 100644 index 00000000..9b7802a9 --- /dev/null +++ b/features/profile-search/components/index.ts @@ -0,0 +1,2 @@ +export * from './empty-keyword'; +export * from './empty-search-result'; diff --git a/features/profile-search/index.ts b/features/profile-search/index.ts new file mode 100644 index 00000000..d86c3a84 --- /dev/null +++ b/features/profile-search/index.ts @@ -0,0 +1,2 @@ +export * from './sections'; +export * from './types'; diff --git a/features/profile-search/sections/index.ts b/features/profile-search/sections/index.ts new file mode 100644 index 00000000..e6a839a4 --- /dev/null +++ b/features/profile-search/sections/index.ts @@ -0,0 +1,2 @@ +export * from './search-bar-section'; +export * from './search-result-section'; diff --git a/features/profile-search/sections/search-bar-section.tsx b/features/profile-search/sections/search-bar-section.tsx new file mode 100644 index 00000000..64a48f4d --- /dev/null +++ b/features/profile-search/sections/search-bar-section.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { debounce } from 'lodash'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; + +import { SearchBar } from '@/components/molecules'; +import { css } from '@/styled-system/css'; + +export const SearchBarSection = ({ keyword }: { keyword: string }) => { + const router = useRouter(); + const [searchKeyword, setSearchKeyword] = useState(keyword); + + const handleChangeKeyword = debounce((keyword: string) => { + setSearchKeyword(keyword); + }, 400); + + const setKeywordParams = useCallback( + (keyword: string) => { + const params = new URL(window.location.href); + params.searchParams.set('keyword', keyword); + + router.replace(params.toString()); + }, + [router], + ); + + useEffect(() => { + setKeywordParams(searchKeyword); + }, [searchKeyword, setKeywordParams]); + + return ( +
+ +
+ ); +}; + +const containerStyle = css({ + p: '8px 20px', +}); diff --git a/features/profile-search/sections/search-result-section.tsx b/features/profile-search/sections/search-result-section.tsx new file mode 100644 index 00000000..05af9711 --- /dev/null +++ b/features/profile-search/sections/search-result-section.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { ProfileList } from '@/components/molecules'; +import { flex } from '@/styled-system/patterns'; + +import { useProfileSearch } from '../apis/use-profile-search'; +import { EmptyKeyword, EmptySearchResult } from '../components'; + +export const SearchResultSection = ({ keyword }: { keyword: string }) => { + const { + flattenData, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + isFetching, + } = useProfileSearch(keyword); + + const fetchNextData = () => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }; + + if (!keyword.length) return ; + if (flattenData.length === 0 && !isFetching) + return ; + return ( +
+ +
+ ); +}; + +const containerStyle = flex({ + direction: 'column', + gap: '12px', + p: '16px 20px', +}); diff --git a/features/profile-search/types/index.ts b/features/profile-search/types/index.ts new file mode 100644 index 00000000..9b158466 --- /dev/null +++ b/features/profile-search/types/index.ts @@ -0,0 +1,8 @@ +import { Response } from '@/apis'; +import { MemberProfile } from '@/types'; + +export type ProfileSearch = Response<{ + memberInfoResponses: MemberProfile[]; + cursorId: number; + hasNext: boolean; +}>; diff --git a/features/record-detail/components/cheer-modal-item.tsx b/features/record-detail/components/cheer-modal-item.tsx index 969f18ff..4c4d82fb 100644 --- a/features/record-detail/components/cheer-modal-item.tsx +++ b/features/record-detail/components/cheer-modal-item.tsx @@ -1,4 +1,4 @@ -import { Image } from '@/components/atoms'; +import { ProfileImage } from '@/components/molecules'; import { css } from '@/styled-system/css'; import { flex } from '@/styled-system/patterns'; @@ -23,7 +23,7 @@ export const CheerModalItem = ({