diff --git a/apps/web/e2e/setup/mockApiServer.ts b/apps/web/e2e/setup/mockApiServer.ts index f2d21d7f5..e9230e2b8 100644 --- a/apps/web/e2e/setup/mockApiServer.ts +++ b/apps/web/e2e/setup/mockApiServer.ts @@ -63,6 +63,7 @@ const SSR_MOCK_ROUTES: Record = { [`GET ${ENDPOINTS.WISHLISTS}`]: createApiSuccess(MOCK_WISHLIST_ENTRIES), [`GET ${ENDPOINTS.WISHLIST(1)}`]: createApiSuccess(MOCK_WISH_DETAIL), [`GET ${ENDPOINTS.FOLDERS}`]: createApiSuccess(MOCK_FOLDERS), + [`GET ${ENDPOINTS.WISHLISTS_COUNT}`]: createApiSuccess({ count: MOCK_WISHLIST_ENTRIES.length }), }; const readCookie = (req: http.IncomingMessage, name: string) => diff --git a/apps/web/e2e/specs/wish/allWishes.spec.ts b/apps/web/e2e/specs/wish/allWishes.spec.ts new file mode 100644 index 000000000..50abaf844 --- /dev/null +++ b/apps/web/e2e/specs/wish/allWishes.spec.ts @@ -0,0 +1,85 @@ +import { ENDPOINTS } from '@/consts/api'; + +import { expect, test } from '@e2e/fixtures/mockApiFixture'; +import { MOCK_FOLDER, MOCK_FOLDERS } from '@e2e/mocks/folder'; +import { MOCK_MEMBER_ME } from '@e2e/mocks/me'; +import { MOCK_WISHLIST_ENTRIES } from '@e2e/mocks/wish'; + +const [FIRST_ENTRY] = MOCK_WISHLIST_ENTRIES; +const FIRST_WISH_NAME = FIRST_ENTRY?.item.name ?? ''; +const FIRST_WISH_ID = FIRST_ENTRY?.wish.id ?? 0; + +test('전체 위시 탭에 위시 카드가 보인다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + + await page.goto('/wish'); + + for (const { item } of MOCK_WISHLIST_ENTRIES) { + await expect(page.getByText(item.name ?? '', { exact: true })).toBeVisible(); + } + await expect(page.getByText(`상품 ${MOCK_WISHLIST_ENTRIES.length}`)).toBeVisible(); +}); + +test('편집 모드에서 위시를 골라 삭제하면 삭제 요청을 보낸다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.delete(ENDPOINTS.WISHLISTS, null); + api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES); + api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length }); + api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS); + + await page.goto('/wish'); + await page.getByRole('button', { name: '편집' }).click(); + await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click(); + await page.getByRole('button', { name: '삭제', exact: true }).click(); + + const deleteRequest = page.waitForRequest( + request => request.method() === 'DELETE' && request.url().includes(ENDPOINTS.WISHLISTS) + ); + await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click(); + + expect(new URL((await deleteRequest).url()).searchParams.get('ids')).toBe(String(FIRST_WISH_ID)); + await expect(page.getByText('상품 1개를 삭제했어요.')).toBeVisible(); +}); + +test('편집 모드에서 폴더를 고르면 그 폴더에 위시를 담는다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.post(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 8 }); + api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES); + api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length }); + api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS); + + await page.goto('/wish'); + await page.getByRole('button', { name: '편집' }).click(); + await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click(); + await page.getByRole('button', { name: '폴더 이동' }).click(); + + const addRequest = page.waitForRequest( + request => + request.method() === 'POST' && request.url().endsWith(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id)) + ); + await page.getByRole('button', { name: new RegExp(MOCK_FOLDER.name) }).click(); + + expect((await addRequest).postDataJSON()).toEqual({ wishIds: [FIRST_WISH_ID] }); + await expect(page.getByText(`상품을 '${MOCK_FOLDER.name}' 폴더로 이동했어요.`)).toBeVisible(); +}); + +test('검색어를 입력하면 검색어로 위시를 조회해 결과를 보여준다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.getPage(ENDPOINTS.WISHLISTS, [FIRST_ENTRY]); + api.get(ENDPOINTS.WISHLISTS_COUNT, { count: 1 }); + + await page.goto('/wish'); + await page.getByRole('button', { name: '검색' }).click(); + + const searchInput = page.getByRole('textbox', { name: '상품명 검색' }); + await searchInput.fill('스니커즈'); + + const searchRequest = page.waitForRequest( + request => request.method() === 'GET' && request.url().includes('q=') + ); + await searchInput.press('Enter'); + + expect(new URL((await searchRequest).url()).searchParams.get('q')).toBe('스니커즈'); + await expect(page.getByText('검색 결과 1개')).toBeVisible(); + await expect(page.getByRole('dialog').getByText(FIRST_WISH_NAME, { exact: true })).toBeVisible(); +}); diff --git a/apps/web/e2e/specs/wish/folderTab.spec.ts b/apps/web/e2e/specs/wish/folderTab.spec.ts index 0f876c930..d0d513c6f 100644 --- a/apps/web/e2e/specs/wish/folderTab.spec.ts +++ b/apps/web/e2e/specs/wish/folderTab.spec.ts @@ -6,6 +6,7 @@ import { expect, test } from '@e2e/fixtures/mockApiFixture'; import { setSsrEmpty } from '@e2e/helpers/ssrEmpty'; import { MOCK_FOLDER, MOCK_FOLDERS } from '@e2e/mocks/folder'; import { MOCK_MEMBER_ME } from '@e2e/mocks/me'; +import { MOCK_WISHLIST_ENTRIES } from '@e2e/mocks/wish'; test('폴더 탭에 폴더 이름과 위시 개수가 보인다', async ({ page, api }) => { api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); @@ -84,6 +85,8 @@ test('폴더를 삭제하면 삭제 요청을 보내고 확인창이 닫힌다', api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS); api.delete(ENDPOINTS.FOLDER(MOCK_FOLDER.id), null); + api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES); + api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length }); await page.goto('/wish?tab=folder'); await page.getByRole('button', { name: `${MOCK_FOLDER.name} 폴더 메뉴` }).click(); diff --git a/apps/web/src/app/archive/wish/_apis/deleteWishes.ts b/apps/web/src/apis/deleteWishes.ts similarity index 100% rename from apps/web/src/app/archive/wish/_apis/deleteWishes.ts rename to apps/web/src/apis/deleteWishes.ts diff --git a/apps/web/src/app/archive/wish/_hooks/useDeleteWishes.ts b/apps/web/src/app/archive/wish/_hooks/useDeleteWishes.ts index 9e0fab44b..346d3ce1a 100644 --- a/apps/web/src/app/archive/wish/_hooks/useDeleteWishes.ts +++ b/apps/web/src/app/archive/wish/_hooks/useDeleteWishes.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { toast } from 'sonner'; -import { deleteWishes } from '../_apis/deleteWishes'; +import { deleteWishes } from '@/apis/deleteWishes'; export const useDeleteWishes = (options?: { onSuccess?: () => void }) => { const [selectedIds, setSelectedIds] = useState>(new Set()); diff --git a/apps/web/src/app/notification/_components/NotificationContent.tsx b/apps/web/src/app/notification/_components/NotificationContent.tsx index 4d3816b99..36039fc2b 100644 --- a/apps/web/src/app/notification/_components/NotificationContent.tsx +++ b/apps/web/src/app/notification/_components/NotificationContent.tsx @@ -5,13 +5,13 @@ import { useState } from 'react'; import { CheckCircledIconOutline } from '@/assets/icons'; import Button from '@/components/button'; +import useIntersectionObserver from '@/hooks/useIntersectionObserver'; import { usePostNotificationsRead } from '@/hooks/usePostNotificationsRead'; import { cn } from '@/utils/cn'; import { formatTimeKo } from '@/utils/formatDate'; import { isWebview } from '@/utils/webBridge'; import { useGetNotifications } from '../_hooks/useGetNotifications'; -import useIntersectionObserver from '../_hooks/useIntersectionObserver'; import { usePushPermission } from '../_hooks/usePushPermission'; import { getNotificationRoute } from '../_utils/getNotificationRoute'; import MarkAllReadDialog from './MarkAllReadDialog'; diff --git a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx index f4698597a..da31a7a06 100644 --- a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx +++ b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx @@ -1,21 +1,23 @@ 'use client'; -import { useState } from 'react'; - import { FolderAddIconFill } from '@/assets/icons'; import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; import { Header } from '@/components/header'; +import type { FolderT } from '@/types/folder'; -import { getMockWishImageUrl } from '../_consts/wishMock'; -import type { WishFolderT } from '../_types/wish'; import FolderListThumbnail from './FolderListThumbnail'; import FolderNameForm from './FolderNameForm'; type FolderSelectSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; - folders: WishFolderT[]; - onSelect: (folder: WishFolderT) => void; + folders: FolderT[]; + isAddView: boolean; + onAddViewChange: (isAddView: boolean) => void; + addErrorMessage: string | null; + isAddPending: boolean; + onAddNameChange: () => void; + onSelect: (folder: FolderT) => void; onAddFolder: (name: string) => void; }; @@ -23,21 +25,20 @@ function FolderSelectSheet({ open, onOpenChange, folders, + isAddView, + onAddViewChange, + addErrorMessage, + isAddPending, + onAddNameChange, onSelect, onAddFolder, }: FolderSelectSheetProps) { - const [isAddView, setIsAddView] = useState(false); - const handleOpenChange = (isOpen: boolean) => { - if (!isOpen) setIsAddView(false); + if (!isOpen && isAddPending) return; + if (!isOpen) onAddViewChange(false); onOpenChange(isOpen); }; - const handleAddSubmit = (name: string) => { - onAddFolder(name); - setIsAddView(false); - }; - return ( @@ -47,8 +48,11 @@ function FolderSelectSheet({ {isAddView ? ( setIsAddView(false)} - onSubmit={handleAddSubmit} + submitErrorMessage={addErrorMessage} + isPending={isAddPending} + onBack={() => !isAddPending && onAddViewChange(false)} + onNameChange={onAddNameChange} + onSubmit={onAddFolder} /> ) : (
@@ -62,7 +66,7 @@ function FolderSelectSheet({ diff --git a/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx b/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx index bc1daa38b..2475a1d58 100644 --- a/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx +++ b/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx @@ -5,17 +5,18 @@ import { type ComponentProps, type ComponentType, type SVGProps, useState } from import { FolderIconFill, TrashIconFill } from '@/assets/icons'; import ConfirmDialog from '@/components/common/confirm-dialog'; import { Z_INDEX } from '@/consts/zIndex'; +import { useGetFolders } from '@/hooks/useGetFolders'; +import type { FolderT } from '@/types/folder'; import { cn } from '@/utils/cn'; -import { MOCK_FOLDERS } from '../_consts/wishMock'; -import type { WishFolderT } from '../_types/wish'; +import { usePostFolder } from '../_hooks/usePostFolder'; import FolderSelectSheet from './FolderSelectSheet'; import './WishEditActionBar.css'; type WishEditActionBarProps = { selectedCount: number; currentFolderId?: number; - onMove: (folder: WishFolderT) => void; + onMove: (folder: FolderT) => void; onDelete: () => void; deleteConfirm: Pick, 'title' | 'description' | 'icon'>; }; @@ -60,19 +61,26 @@ function WishEditActionBar({ deleteConfirm, }: WishEditActionBarProps) { const [isFolderSheetOpen, setIsFolderSheetOpen] = useState(false); + const [isAddFolderView, setIsAddFolderView] = useState(false); + const [addFolderErrorMessage, setAddFolderErrorMessage] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - /** TEMP: API 연동 시 폴더 목록 조회·생성 API 로 교체 */ - const [folders, setFolders] = useState(() => - MOCK_FOLDERS.filter(({ id }) => id !== currentFolderId) - ); + + const { foldersData } = useGetFolders(); + const folders = foldersData.filter(({ id }) => id !== currentFolderId); + + const { postFolderMutation, isPostFolderPending } = usePostFolder({ + onSuccess: () => setIsAddFolderView(false), + onNameError: setAddFolderErrorMessage, + }); const isDisabled = selectedCount === 0; - const handleAddFolder = (name: string) => { - setFolders(prev => [{ id: Date.now(), name, wishIds: [] }, ...prev]); + const handleAddFolderViewChange = (isAddView: boolean) => { + setAddFolderErrorMessage(null); + setIsAddFolderView(isAddView); }; - const handleSelectFolder = (folder: WishFolderT) => { + const handleSelectFolder = (folder: FolderT) => { setIsFolderSheetOpen(false); onMove(folder); }; @@ -109,8 +117,13 @@ function WishEditActionBar({ open={isFolderSheetOpen} onOpenChange={setIsFolderSheetOpen} folders={folders} + isAddView={isAddFolderView} + onAddViewChange={handleAddFolderViewChange} + addErrorMessage={addFolderErrorMessage} + isAddPending={isPostFolderPending} + onAddNameChange={() => setAddFolderErrorMessage(null)} onSelect={handleSelectFolder} - onAddFolder={handleAddFolder} + onAddFolder={postFolderMutation} /> void } = {}) => { + const queryClient = useQueryClient(); + + const { mutate: deleteWishesMutation, isPending: isDeleteWishesPending } = useMutation({ + mutationFn: deleteWishes, + onSuccess: (_, wishIds) => { + queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + queryClient.invalidateQueries({ queryKey: ['folders'] }); + wishIds.forEach(wishId => queryClient.removeQueries({ queryKey: ['wish', wishId] })); + toast.success(`상품 ${wishIds.length}개를 삭제했어요.`); + onSuccess?.(); + }, + onError: error => { + if (isGlobalNetError(error)) return; + + toast.error(getApiErrorMessage(error)); + queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + }, + }); + + return { deleteWishesMutation, isDeleteWishesPending }; +}; diff --git a/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts b/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts new file mode 100644 index 000000000..8b44f8b0f --- /dev/null +++ b/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts @@ -0,0 +1,42 @@ +import { ERROR_CODE } from '@piki/core'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import type { FolderT } from '@/types/folder'; +import { getApiErrorCode, isGlobalNetError } from '@/utils/apiError'; +import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; + +import { postFolderWishes } from '../_apis/postFolderWishes'; + +type PostFolderWishesVariablesT = { + folder: Pick; + wishIds: number[]; +}; + +export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } = {}) => { + const queryClient = useQueryClient(); + + const { mutate: postFolderWishesMutation, isPending: isPostFolderWishesPending } = useMutation({ + mutationFn: ({ folder, wishIds }: PostFolderWishesVariablesT) => + postFolderWishes(folder.id, { wishIds }), + onSuccess: (_, { folder }) => { + queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + queryClient.invalidateQueries({ queryKey: ['folders'] }); + toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`); + onSuccess?.(); + }, + onError: error => { + if (isGlobalNetError(error)) return; + + toast.error(getApiErrorMessage(error)); + + const code = getApiErrorCode(error); + if (code === ERROR_CODE.FOLDER_NOT_FOUND) + queryClient.invalidateQueries({ queryKey: ['folders'] }); + if (code === ERROR_CODE.WISH_FORBIDDEN_ITEMS) + queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + }, + }); + + return { postFolderWishesMutation, isPostFolderWishesPending }; +}; diff --git a/apps/web/src/app/wish/_components/AllWishTabContent.tsx b/apps/web/src/app/wish/_components/AllWishTabContent.tsx index 29ca62b38..d00892d48 100644 --- a/apps/web/src/app/wish/_components/AllWishTabContent.tsx +++ b/apps/web/src/app/wish/_components/AllWishTabContent.tsx @@ -1,4 +1,10 @@ +'use client'; + import WishGrid from '@/components/common/wish-grid'; +import { ITEM_STATUS } from '@/consts/item'; +import { useGetWishes } from '@/hooks/useGetWishes'; +import useIntersectionObserver from '@/hooks/useIntersectionObserver'; +import { useSSEFallback } from '@/hooks/useSSEFallback'; import type { AllWishTabContentProps } from '../_types/wishTab'; import WishAddCard from './WishAddCard'; @@ -6,8 +12,21 @@ import WishAddCard from './WishAddCard'; function AllWishTabContent(props: AllWishTabContentProps) { const isEditMode = Boolean(props.selectedWishIds); + const { wishesData, fetchNextPage, hasNextPage, isFetchingNextPage } = useGetWishes(); + const hasPendingItem = wishesData.some( + ({ item }) => item.status === ITEM_STATUS.PARSING || item.refreshing + ); + + useSSEFallback(['wishlists'], hasPendingItem); + const bottomRef = useIntersectionObserver(fetchNextPage, hasNextPage && !isFetchingNextPage); + /** TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: all) 추가 */ - return } />; + return ( + <> + } /> +
+ + ); } export default AllWishTabContent; diff --git a/apps/web/src/app/wish/_components/WishListContent.tsx b/apps/web/src/app/wish/_components/WishListContent.tsx index b030d1d29..25be9d8a5 100644 --- a/apps/web/src/app/wish/_components/WishListContent.tsx +++ b/apps/web/src/app/wish/_components/WishListContent.tsx @@ -2,18 +2,19 @@ import { useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { toast } from 'sonner'; import { Z_INDEX } from '@/consts/zIndex'; +import { useGetWishesCount } from '@/hooks/useGetWishesCount'; +import type { FolderT } from '@/types/folder'; import { cn } from '@/utils/cn'; import { getScrollContainer } from '@/utils/scrollRestoration'; import WishEditActionBar from '../_common/_components/WishEditActionBar'; import WishEditHeader from '../_common/_components/WishEditHeader'; -import { MOCK_WISHES } from '../_common/_consts/wishMock'; import { WISH_MODE } from '../_common/_consts/wishMode'; +import { useDeleteWishes } from '../_common/_hooks/useDeleteWishes'; +import { usePostFolderWishes } from '../_common/_hooks/usePostFolderWishes'; import { useWishEditMode } from '../_common/_hooks/useWishEditMode'; -import type { WishFolderT } from '../_common/_types/wish'; import { WISH_TAB } from '../_consts/wishTab'; import { parseWishTabParam } from '../_utils/wishSearchParams'; import WishSearch from './WishSearch'; @@ -27,8 +28,7 @@ function WishListContent() { const searchParams = useSearchParams(); const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; - /** TEMP: API 연동 시 위시 목록 조회 응답으로 교체 */ - const [wishes, setWishes] = useState(MOCK_WISHES); + const { wishesCountData } = useGetWishesCount(); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isTitleHidden, setIsTitleHidden] = useState(false); const { @@ -40,18 +40,14 @@ function WishListContent() { handleExitEditMode, } = useWishEditMode(); - /** TODO: 폴더 추가 API 연결. 기존 소속 폴더는 유지됨 */ - const handleMove = (folder: WishFolderT) => { - handleClearSelection(); - toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`); - }; + const { postFolderWishesMutation } = usePostFolderWishes({ onSuccess: handleClearSelection }); + const { deleteWishesMutation } = useDeleteWishes({ onSuccess: handleClearSelection }); - /** TODO: 위시 삭제 API 연결 */ - const handleDelete = () => { - setWishes(prev => prev.filter(({ wish }) => !selectedWishIds.has(wish.id))); - handleClearSelection(); - toast.success(`상품 ${selectedWishIds.size}개를 삭제했어요.`); - }; + /** 전체 위시에서는 기존 소속 폴더를 유지한 채 대상 폴더에 추가 */ + const handleMove = (folder: FolderT) => + postFolderWishesMutation({ folder, wishIds: Array.from(selectedWishIds) }); + + const handleDelete = () => deleteWishesMutation(Array.from(selectedWishIds)); /** 스크롤 시 제목 부분을 숨김/노출 처리 함 */ useEffect(() => { @@ -93,7 +89,7 @@ function WishListContent() { )} setIsSearchOpen(true)} @@ -101,7 +97,6 @@ function WishListContent() {
@@ -118,7 +113,7 @@ function WishListContent() { /> )} - {isSearchOpen && setIsSearchOpen(false)} />} + {isSearchOpen && setIsSearchOpen(false)} />} ); } diff --git a/apps/web/src/app/wish/_components/WishSearch.tsx b/apps/web/src/app/wish/_components/WishSearch.tsx index be4068ec7..9741cdb55 100644 --- a/apps/web/src/app/wish/_components/WishSearch.tsx +++ b/apps/web/src/app/wish/_components/WishSearch.tsx @@ -8,28 +8,32 @@ import WishGrid from '@/components/common/wish-grid'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; import { Header } from '@/components/header'; import Input from '@/components/input'; -import type { WishListItemT } from '@/types/wish'; +import useIntersectionObserver from '@/hooks/useIntersectionObserver'; import { cn } from '@/utils/cn'; +import { useSearchWishes } from '../_hooks/useSearchWishes'; + type WishSearchProps = { - wishes: WishListItemT[]; onClose: () => void; }; -function WishSearch({ wishes, onClose }: WishSearchProps) { +function WishSearch({ onClose }: WishSearchProps) { const inputRef = useRef(null); const [keyword, setKeyword] = useState(''); const [submittedKeyword, setSubmittedKeyword] = useState(null); const [isFocused, setIsFocused] = useState(true); - /** TODO: API 연동 시 위시 검색 API 로 교체 */ - const results = - submittedKeyword === null - ? [] - : wishes.filter(({ item }) => - item.name?.toLowerCase().includes(submittedKeyword.toLowerCase()) - ); + const { + searchWishesData, + searchWishesCountData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isSearchWishesPending, + } = useSearchWishes(submittedKeyword); + const bottomRef = useIntersectionObserver(fetchNextPage, hasNextPage && !isFetchingNextPage); + const isSearching = isFocused || submittedKeyword === null; const handleSubmit = (event: FormEvent) => { @@ -118,18 +122,22 @@ function WishSearch({ wishes, onClose }: WishSearchProps) {
- {isSearching ? ( + {isSearching && (
- ) : ( + )} + {!isSearching && !isSearchWishesPending && (

- 검색 결과 {results.length}개 + 검색 결과 {searchWishesCountData ?? searchWishesData.length}개

- {results.length > 0 ? ( - + {searchWishesData.length > 0 ? ( + <> + +
+ ) : (
diff --git a/apps/web/src/app/wish/_hooks/useSearchWishes.ts b/apps/web/src/app/wish/_hooks/useSearchWishes.ts new file mode 100644 index 000000000..dfccb8517 --- /dev/null +++ b/apps/web/src/app/wish/_hooks/useSearchWishes.ts @@ -0,0 +1,29 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; +import { wishesCountQueryOptions } from '@/apis/getWishesCount'; + +export const useSearchWishes = (keyword: string | null) => { + const request = { q: keyword ?? '' }; + const enabled = keyword !== null; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ + ...wishesInfiniteQueryOptions(request), + enabled, + }); + const { data: searchWishesCountData } = useQuery({ + ...wishesCountQueryOptions(request), + enabled, + }); + + const searchWishesData = data?.pages.flatMap(page => page.data) ?? []; + + return { + searchWishesData, + searchWishesCountData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isSearchWishesPending: isPending, + }; +}; diff --git a/apps/web/src/app/wish/_types/wishTab.ts b/apps/web/src/app/wish/_types/wishTab.ts index 7dafc3dd4..151cd690d 100644 --- a/apps/web/src/app/wish/_types/wishTab.ts +++ b/apps/web/src/app/wish/_types/wishTab.ts @@ -1,11 +1,8 @@ -import type { WishListItemT } from '@/types/wish'; - import type { WISH_TAB } from '../_consts/wishTab'; export type WishTabT = (typeof WISH_TAB)[keyof typeof WISH_TAB]; export type AllWishTabContentProps = { - wishes: WishListItemT[]; selectedWishIds?: Set; onToggleSelect?: (wishId: number) => void; }; diff --git a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx index 4ec6d508c..d39279d05 100644 --- a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx +++ b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx @@ -11,13 +11,13 @@ import Spacing from '@/components/spacing'; import { ROUTES } from '@/consts/route'; import { Z_INDEX } from '@/consts/zIndex'; import { useBackWithFallback } from '@/hooks/useBackWithFallback'; +import type { FolderT } from '@/types/folder'; import WishEditActionBar from '../../../_common/_components/WishEditActionBar'; import WishEditHeader from '../../../_common/_components/WishEditHeader'; import { MOCK_FOLDERS, MOCK_WISHES } from '../../../_common/_consts/wishMock'; import { WISH_MODE } from '../../../_common/_consts/wishMode'; import { useWishEditMode } from '../../../_common/_hooks/useWishEditMode'; -import type { WishFolderT } from '../../../_common/_types/wish'; import FolderWishAddDialog from './FolderWishAddDialog'; const MAX_DISPLAY_COUNT = 999; @@ -58,7 +58,7 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { if (!folder) return null; /** TODO: 폴더 이동 API 연결 */ - const handleMove = (targetFolder: WishFolderT) => { + const handleMove = (targetFolder: FolderT) => { setWishes(prev => prev.filter(({ wish }) => !selectedWishIds.has(wish.id))); handleClearSelection(); toast.success(`상품을 '${targetFolder.name}' 폴더로 이동했어요.`); diff --git a/apps/web/src/app/wish/page.tsx b/apps/web/src/app/wish/page.tsx index 75ad502de..ad9fa515e 100644 --- a/apps/web/src/app/wish/page.tsx +++ b/apps/web/src/app/wish/page.tsx @@ -1,6 +1,8 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { foldersQueryOptions } from '@/apis/getFolders'; +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; +import { wishesCountQueryOptions } from '@/apis/getWishesCount'; import { getQueryClient } from '@/utils/queryClient'; import { serverPrefetch } from '@/utils/serverPrefetch'; @@ -9,7 +11,11 @@ import WishListContent from './_components/WishListContent'; async function WishPage() { const queryClient = getQueryClient(); - await serverPrefetch(() => queryClient.fetchQuery(foldersQueryOptions)); + await Promise.all([ + serverPrefetch(() => queryClient.fetchQuery(foldersQueryOptions)), + serverPrefetch(() => queryClient.fetchInfiniteQuery(wishesInfiniteQueryOptions())), + serverPrefetch(() => queryClient.fetchQuery(wishesCountQueryOptions())), + ]); return ( diff --git a/apps/web/src/components/common/wish-grid/WishCard.tsx b/apps/web/src/components/common/wish-grid/WishCard.tsx index b5d5cfbf9..dce092d3d 100644 --- a/apps/web/src/components/common/wish-grid/WishCard.tsx +++ b/apps/web/src/components/common/wish-grid/WishCard.tsx @@ -10,7 +10,7 @@ type WishCardProps = { price: number | null; imageUrl: string | null; sourcePlatform: string | null; - discountRate: number | null; + discountRate?: number | null; /** 내가 플레이한 토너먼트에서 우승한 적 있는지 */ isWinner?: boolean; preload?: boolean; @@ -28,7 +28,7 @@ function WishCard({ price, imageUrl, sourcePlatform, - discountRate, + discountRate = null, isWinner = false, preload = false, }: WishCardProps) { diff --git a/apps/web/src/components/common/wish-grid/index.tsx b/apps/web/src/components/common/wish-grid/index.tsx index 69c46a803..cabe11c4e 100644 --- a/apps/web/src/components/common/wish-grid/index.tsx +++ b/apps/web/src/components/common/wish-grid/index.tsx @@ -2,12 +2,12 @@ import type { ReactNode } from 'react'; import { CheckboxEmptyIconFill, CheckboxSelectedIconFill } from '@/assets/icons'; import { Z_INDEX } from '@/consts/zIndex'; -import type { WishListItemT } from '@/types/wish'; +import type { GetWishlistResponseT } from '@/types/wish'; import WishCard from './WishCard'; type WishGridProps = { - wishes: WishListItemT[]; + wishes: GetWishlistResponseT[]; selectedWishIds?: Set; onToggleSelect?: (wishId: number) => void; leadingItem?: ReactNode; @@ -28,7 +28,6 @@ function WishGrid({ wishes, selectedWishIds, onToggleSelect, leadingItem }: Wish price={item.price} imageUrl={item.imageUrl} sourcePlatform={item.sourcePlatform} - discountRate={item.discountRate} isWinner={winnerTournamentIds.length > 0} preload={index < PRELOAD_COUNT} /> diff --git a/apps/web/src/hooks/useGetWishes.ts b/apps/web/src/hooks/useGetWishes.ts new file mode 100644 index 000000000..fd1f9efd7 --- /dev/null +++ b/apps/web/src/hooks/useGetWishes.ts @@ -0,0 +1,14 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; + +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; +import type { GetWishesRequestT } from '@/types/wish'; + +export const useGetWishes = (request: GetWishesRequestT = {}) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSuspenseInfiniteQuery( + wishesInfiniteQueryOptions(request) + ); + + const wishesData = data.pages.flatMap(page => page.data); + + return { wishesData, fetchNextPage, hasNextPage, isFetchingNextPage }; +}; diff --git a/apps/web/src/hooks/useGetWishesCount.ts b/apps/web/src/hooks/useGetWishesCount.ts new file mode 100644 index 000000000..0c0588af5 --- /dev/null +++ b/apps/web/src/hooks/useGetWishesCount.ts @@ -0,0 +1,10 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { wishesCountQueryOptions } from '@/apis/getWishesCount'; +import type { GetWishesRequestT } from '@/types/wish'; + +export const useGetWishesCount = (request: GetWishesRequestT = {}) => { + const { data: wishesCountData } = useSuspenseQuery(wishesCountQueryOptions(request)); + + return { wishesCountData }; +}; diff --git a/apps/web/src/app/notification/_hooks/useIntersectionObserver.ts b/apps/web/src/hooks/useIntersectionObserver.ts similarity index 100% rename from apps/web/src/app/notification/_hooks/useIntersectionObserver.ts rename to apps/web/src/hooks/useIntersectionObserver.ts