From 7a73126cffdf0cd750acd57e93f2531019756250 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Fri, 30 Oct 2026 23:57:51 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B0=90=EC=A7=80=20=ED=9B=85=EA=B3=BC=20?= =?UTF-8?q?=EC=9C=84=EC=8B=9C=20=EC=97=AC=EB=9F=AC=20=EA=B0=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20API=20=EA=B3=B5=EC=9A=A9=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useIntersectionObserver 를 notification 에서 src/hooks 로 이관 - deleteWishes 를 archive/wish 에서 src/apis 로 이관 - 원인: 새 위시 화면도 같이 사용 --- apps/web/src/{app/archive/wish/_apis => apis}/deleteWishes.ts | 0 apps/web/src/app/archive/wish/_hooks/useDeleteWishes.ts | 2 +- .../src/app/notification/_components/NotificationContent.tsx | 2 +- .../notification/_hooks => hooks}/useIntersectionObserver.ts | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename apps/web/src/{app/archive/wish/_apis => apis}/deleteWishes.ts (100%) rename apps/web/src/{app/notification/_hooks => hooks}/useIntersectionObserver.ts (100%) 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/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 From 9e5be6c47c154c6f6f2ed28be59022c5ad7df48e Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 31 Oct 2026 00:03:16 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EC=9C=84=EC=8B=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EC=9E=85=EB=A0=A5=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=EC=8B=9C=20=EB=AA=A9=EB=A1=9D=20=EC=9D=91=EB=8B=B5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WishGrid 가 GetWishlistResponseT 를 받도록 변경 - WishCard 의 discountRate 를 선택값으로 변경 - 원인: 위시 목록 응답에 할인율 없음 --- apps/web/src/components/common/wish-grid/WishCard.tsx | 4 ++-- apps/web/src/components/common/wish-grid/index.tsx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) 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} /> From a3b7db51cee373ae986cabdaf72fbf548614ef4c Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 31 Oct 2026 00:03:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EC=9C=84=EC=8B=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A8=EB=93=9C=20=EC=82=AD=EC=A0=9C=EC=99=80=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EB=8B=B4=EA=B8=B0=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 폴더 선택 시트를 폴더 목록 조회 API 로 교체 - 표지는 coverImageUrls 첫 장 · 개수는 wishCount - 시트 안 새 폴더 만들기를 폴더 생성 API 로 연결 - 생성 후 목록 화면으로 복귀 - FOLDER-002 · FOLDER-003 은 이름 입력 화면 안 문구 - 여러 개 삭제 · 폴더에 담기 mutation 추가 - 성공 시 위시 목록과 폴더 목록 갱신 --- .../_common/_components/FolderSelectSheet.tsx | 43 ++++++++++--------- .../_common/_components/WishEditActionBar.tsx | 35 ++++++++++----- .../wish/_common/_hooks/useDeleteWishes.ts | 29 +++++++++++++ .../_common/_hooks/usePostFolderWishes.ts | 42 ++++++++++++++++++ .../_components/FolderDetailContent.tsx | 4 +- 5 files changed, 119 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/app/wish/_common/_hooks/useDeleteWishes.ts create mode 100644 apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts diff --git a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx index f4698597a..4daba4437 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,19 @@ 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) onAddViewChange(false); onOpenChange(isOpen); }; - const handleAddSubmit = (name: string) => { - onAddFolder(name); - setIsAddView(false); - }; - return ( @@ -47,8 +47,11 @@ function FolderSelectSheet({ {isAddView ? ( setIsAddView(false)} - onSubmit={handleAddSubmit} + submitErrorMessage={addErrorMessage} + isPending={isAddPending} + onBack={() => onAddViewChange(false)} + onNameChange={onAddNameChange} + onSubmit={onAddFolder} /> ) : (
@@ -62,7 +65,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..207965553 --- /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_NOT_FOUND) + queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + }, + }); + + return { postFolderWishesMutation, isPostFolderWishesPending }; +}; 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}' 폴더로 이동했어요.`); From b71edc1074745cdadd731a3f85b727c6563a22ce Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 31 Oct 2026 00:03:16 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EC=9C=84?= =?UTF-8?q?=EC=8B=9C=20=ED=83=AD=20=EC=9C=84=EC=8B=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 위시 탭을 getWishes 무한 스크롤로 교체 - 페이지에서 서버 프리페치 - 파싱 중 위시가 있으면 SSE 대비 갱신 - 편집 모드 삭제 · 폴더 이동을 API 로 연결 - 상품 개수는 불러온 개수로 표시. 다음 페이지가 있으면 + 표시 - 원인: 목록 응답에 전체 개수 없음 --- .../wish/_components/AllWishTabContent.tsx | 21 +++++++++++- .../app/wish/_components/WishListContent.tsx | 34 ++++++++----------- .../app/wish/_components/WishTabSummary.tsx | 10 ++++-- apps/web/src/app/wish/_types/wishTab.ts | 3 -- apps/web/src/app/wish/page.tsx | 6 +++- apps/web/src/hooks/useGetWishes.ts | 14 ++++++++ 6 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/hooks/useGetWishes.ts 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..a5f7edeac 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 { useGetWishes } from '@/hooks/useGetWishes'; +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 { wishesData, hasNextPage } = useGetWishes(); 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,8 @@ function WishListContent() { )} setIsSearchOpen(true)} @@ -101,7 +98,6 @@ function WishListContent() {
@@ -118,7 +114,7 @@ function WishListContent() { /> )} - {isSearchOpen && setIsSearchOpen(false)} />} + {isSearchOpen && setIsSearchOpen(false)} />} ); } diff --git a/apps/web/src/app/wish/_components/WishTabSummary.tsx b/apps/web/src/app/wish/_components/WishTabSummary.tsx index 90e7904c7..b493299e4 100644 --- a/apps/web/src/app/wish/_components/WishTabSummary.tsx +++ b/apps/web/src/app/wish/_components/WishTabSummary.tsx @@ -14,11 +14,17 @@ const MAX_DISPLAY_COUNT = 999; type WishTabSummaryProps = { wishCount: number; + hasMoreWishes: boolean; mode: WishModeT; onEdit: () => void; onSearch: () => void; }; +const formatWishCount = (count: number, hasMore: boolean) => { + if (count > MAX_DISPLAY_COUNT) return `${MAX_DISPLAY_COUNT}+`; + return hasMore ? `${count}+` : String(count); +}; + /** 폴더 탭에서만 폴더를 조회해 폴더 조회 실패가 전체 위시 탭에 번지지 않게 함 */ function FolderTabSummary() { const { foldersData } = useGetFolders(); @@ -28,7 +34,7 @@ function FolderTabSummary() { return

폴더 {foldersData.length}개

; } -function WishTabSummary({ wishCount, mode, onEdit, onSearch }: WishTabSummaryProps) { +function WishTabSummary({ wishCount, hasMoreWishes, mode, onEdit, onSearch }: WishTabSummaryProps) { const searchParams = useSearchParams(); const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; @@ -37,7 +43,7 @@ function WishTabSummary({ wishCount, mode, onEdit, onSearch }: WishTabSummaryPro return (

- 상품 {wishCount > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishCount} + 상품 {formatWishCount(wishCount, hasMoreWishes)}

{mode === WISH_MODE.DEFAULT && (
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/page.tsx b/apps/web/src/app/wish/page.tsx index 75ad502de..8557d9b6f 100644 --- a/apps/web/src/app/wish/page.tsx +++ b/apps/web/src/app/wish/page.tsx @@ -1,6 +1,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { foldersQueryOptions } from '@/apis/getFolders'; +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; import { getQueryClient } from '@/utils/queryClient'; import { serverPrefetch } from '@/utils/serverPrefetch'; @@ -9,7 +10,10 @@ 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())), + ]); return ( 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 }; +}; From 461b3da28baf0d82de684bef1ca1d880798dd603 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 31 Oct 2026 00:03:16 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EC=9C=84=EC=8B=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색을 위시 목록 조회의 q 파라미터로 교체 - 검색 결과 무한 스크롤 추가 --- .../src/app/wish/_components/WishSearch.tsx | 40 ++++++++++++------- .../src/app/wish/_hooks/useSearchWishes.ts | 20 ++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/app/wish/_hooks/useSearchWishes.ts diff --git a/apps/web/src/app/wish/_components/WishSearch.tsx b/apps/web/src/app/wish/_components/WishSearch.tsx index be4068ec7..910dc8fed 100644 --- a/apps/web/src/app/wish/_components/WishSearch.tsx +++ b/apps/web/src/app/wish/_components/WishSearch.tsx @@ -8,29 +8,35 @@ 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, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isSearchWishesPending, + } = useSearchWishes(submittedKeyword); + const bottomRef = useIntersectionObserver(fetchNextPage, hasNextPage && !isFetchingNextPage); + const isSearching = isFocused || submittedKeyword === null; + const resultCountLabel = hasNextPage + ? `${searchWishesData.length}+` + : String(searchWishesData.length); const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -118,18 +124,22 @@ function WishSearch({ wishes, onClose }: WishSearchProps) {
- {isSearching ? ( + {isSearching && (
- ) : ( + )} + {!isSearching && !isSearchWishesPending && (

- 검색 결과 {results.length}개 + 검색 결과 {resultCountLabel}개

- {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..7af608948 --- /dev/null +++ b/apps/web/src/app/wish/_hooks/useSearchWishes.ts @@ -0,0 +1,20 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; + +export const useSearchWishes = (keyword: string | null) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ + ...wishesInfiniteQueryOptions({ q: keyword ?? '' }), + enabled: keyword !== null, + }); + + const searchWishesData = data?.pages.flatMap(page => page.data) ?? []; + + return { + searchWishesData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isSearchWishesPending: isPending, + }; +}; From 540027ef3e94d063cd72064cbdb2f7922a89540a Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 31 Oct 2026 00:03:16 +0900 Subject: [PATCH 6/9] =?UTF-8?q?test:=20=EC=A0=84=EC=B2=B4=20=EC=9C=84?= =?UTF-8?q?=EC=8B=9C=20=ED=83=AD=20e2e=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 · 편집 모드 삭제 · 폴더에 담기 · 검색 - 폴더 삭제 시나리오에 위시 목록 재조회 목 추가 - 원인: 폴더 삭제 후 위시 목록도 갱신 --- apps/web/e2e/specs/wish/allWishes.spec.ts | 82 +++++++++++++++++++++++ apps/web/e2e/specs/wish/folderTab.spec.ts | 2 + 2 files changed, 84 insertions(+) create mode 100644 apps/web/e2e/specs/wish/allWishes.spec.ts 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..c1cf0ee58 --- /dev/null +++ b/apps/web/e2e/specs/wish/allWishes.spec.ts @@ -0,0 +1,82 @@ +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.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.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]); + + 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..5c11c49fa 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,7 @@ 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); await page.goto('/wish?tab=folder'); await page.getByRole('button', { name: `${MOCK_FOLDER.name} 폴더 메뉴` }).click(); From 3a0f2cfc588fc12007bf172993ef2295dffcebc3 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Mon, 2 Nov 2026 19:01:02 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=EC=9C=84=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20API=20=EB=A1=9C=20=EC=A0=84=EC=B2=B4=20=EC=9C=84?= =?UTF-8?q?=EC=8B=9C=20=C2=B7=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 위시 탭 상품 개수를 위시 개수 API 로 교체 - 페이지에서 서버 프리페치 - 불러온 개수와 + 표시 제거 - 검색 결과 개수를 검색어 개수 조회로 교체 - 검색을 제출했을 때만 조회 - 원인: 서버가 검색 개수는 느릴 수 있어 제출 때 한 번만 부르도록 안내 - 폴더에 담기 중 WISH-002 면 위시 목록 갱신 - 원인: 서버가 남의 위시 · 지운 위시를 WISH-002(403)로 거절 - e2e 목에 위시 개수 응답 추가 --- apps/web/e2e/setup/mockApiServer.ts | 1 + apps/web/e2e/specs/wish/allWishes.spec.ts | 3 +++ apps/web/e2e/specs/wish/folderTab.spec.ts | 1 + .../src/app/wish/_common/_hooks/usePostFolderWishes.ts | 2 +- apps/web/src/app/wish/_components/WishListContent.tsx | 7 +++---- apps/web/src/app/wish/_components/WishSearch.tsx | 10 ++++++---- apps/web/src/app/wish/_components/WishTabSummary.tsx | 10 ++-------- apps/web/src/app/wish/page.tsx | 2 ++ apps/web/src/hooks/useGetWishesCount.ts | 10 ++++++++++ 9 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/hooks/useGetWishesCount.ts 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 index c1cf0ee58..50abaf844 100644 --- a/apps/web/e2e/specs/wish/allWishes.spec.ts +++ b/apps/web/e2e/specs/wish/allWishes.spec.ts @@ -24,6 +24,7 @@ test('편집 모드에서 위시를 골라 삭제하면 삭제 요청을 보낸 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'); @@ -44,6 +45,7 @@ test('편집 모드에서 폴더를 고르면 그 폴더에 위시를 담는다' 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'); @@ -64,6 +66,7 @@ test('편집 모드에서 폴더를 고르면 그 폴더에 위시를 담는다' 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(); diff --git a/apps/web/e2e/specs/wish/folderTab.spec.ts b/apps/web/e2e/specs/wish/folderTab.spec.ts index 5c11c49fa..d0d513c6f 100644 --- a/apps/web/e2e/specs/wish/folderTab.spec.ts +++ b/apps/web/e2e/specs/wish/folderTab.spec.ts @@ -86,6 +86,7 @@ test('폴더를 삭제하면 삭제 요청을 보내고 확인창이 닫힌다', 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/wish/_common/_hooks/usePostFolderWishes.ts b/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts index 207965553..8b44f8b0f 100644 --- a/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts +++ b/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts @@ -33,7 +33,7 @@ export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } = const code = getApiErrorCode(error); if (code === ERROR_CODE.FOLDER_NOT_FOUND) queryClient.invalidateQueries({ queryKey: ['folders'] }); - if (code === ERROR_CODE.WISH_NOT_FOUND) + if (code === ERROR_CODE.WISH_FORBIDDEN_ITEMS) queryClient.invalidateQueries({ queryKey: ['wishlists'] }); }, }); diff --git a/apps/web/src/app/wish/_components/WishListContent.tsx b/apps/web/src/app/wish/_components/WishListContent.tsx index a5f7edeac..c8eb31e48 100644 --- a/apps/web/src/app/wish/_components/WishListContent.tsx +++ b/apps/web/src/app/wish/_components/WishListContent.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { Z_INDEX } from '@/consts/zIndex'; -import { useGetWishes } from '@/hooks/useGetWishes'; +import { useGetWishesCount } from '@/hooks/useGetWishesCount'; import type { FolderT } from '@/types/folder'; import { cn } from '@/utils/cn'; import { getScrollContainer } from '@/utils/scrollRestoration'; @@ -28,7 +28,7 @@ function WishListContent() { const searchParams = useSearchParams(); const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; - const { wishesData, hasNextPage } = useGetWishes(); + const { wishesCountData = 0 } = useGetWishesCount(); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isTitleHidden, setIsTitleHidden] = useState(false); const { @@ -89,8 +89,7 @@ function WishListContent() { )} setIsSearchOpen(true)} diff --git a/apps/web/src/app/wish/_components/WishSearch.tsx b/apps/web/src/app/wish/_components/WishSearch.tsx index 910dc8fed..a8cf7bab0 100644 --- a/apps/web/src/app/wish/_components/WishSearch.tsx +++ b/apps/web/src/app/wish/_components/WishSearch.tsx @@ -8,6 +8,7 @@ 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 { useGetWishesCount } from '@/hooks/useGetWishesCount'; import useIntersectionObserver from '@/hooks/useIntersectionObserver'; import { cn } from '@/utils/cn'; @@ -34,9 +35,10 @@ function WishSearch({ onClose }: WishSearchProps) { const bottomRef = useIntersectionObserver(fetchNextPage, hasNextPage && !isFetchingNextPage); const isSearching = isFocused || submittedKeyword === null; - const resultCountLabel = hasNextPage - ? `${searchWishesData.length}+` - : String(searchWishesData.length); + const { wishesCountData: searchCount } = useGetWishesCount( + { q: submittedKeyword ?? '' }, + submittedKeyword !== null + ); const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -133,7 +135,7 @@ function WishSearch({ onClose }: WishSearchProps) { className="mx-auto flex w-full max-w-120 flex-col gap-6 pt-1 pb-10" >

- 검색 결과 {resultCountLabel}개 + 검색 결과 {searchCount ?? searchWishesData.length}개

{searchWishesData.length > 0 ? ( <> diff --git a/apps/web/src/app/wish/_components/WishTabSummary.tsx b/apps/web/src/app/wish/_components/WishTabSummary.tsx index b493299e4..90e7904c7 100644 --- a/apps/web/src/app/wish/_components/WishTabSummary.tsx +++ b/apps/web/src/app/wish/_components/WishTabSummary.tsx @@ -14,17 +14,11 @@ const MAX_DISPLAY_COUNT = 999; type WishTabSummaryProps = { wishCount: number; - hasMoreWishes: boolean; mode: WishModeT; onEdit: () => void; onSearch: () => void; }; -const formatWishCount = (count: number, hasMore: boolean) => { - if (count > MAX_DISPLAY_COUNT) return `${MAX_DISPLAY_COUNT}+`; - return hasMore ? `${count}+` : String(count); -}; - /** 폴더 탭에서만 폴더를 조회해 폴더 조회 실패가 전체 위시 탭에 번지지 않게 함 */ function FolderTabSummary() { const { foldersData } = useGetFolders(); @@ -34,7 +28,7 @@ function FolderTabSummary() { return

폴더 {foldersData.length}개

; } -function WishTabSummary({ wishCount, hasMoreWishes, mode, onEdit, onSearch }: WishTabSummaryProps) { +function WishTabSummary({ wishCount, mode, onEdit, onSearch }: WishTabSummaryProps) { const searchParams = useSearchParams(); const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; @@ -43,7 +37,7 @@ function WishTabSummary({ wishCount, hasMoreWishes, mode, onEdit, onSearch }: Wi return (

- 상품 {formatWishCount(wishCount, hasMoreWishes)} + 상품 {wishCount > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishCount}

{mode === WISH_MODE.DEFAULT && (
diff --git a/apps/web/src/app/wish/page.tsx b/apps/web/src/app/wish/page.tsx index 8557d9b6f..ad9fa515e 100644 --- a/apps/web/src/app/wish/page.tsx +++ b/apps/web/src/app/wish/page.tsx @@ -2,6 +2,7 @@ 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'; @@ -13,6 +14,7 @@ async function WishPage() { await Promise.all([ serverPrefetch(() => queryClient.fetchQuery(foldersQueryOptions)), serverPrefetch(() => queryClient.fetchInfiniteQuery(wishesInfiniteQueryOptions())), + serverPrefetch(() => queryClient.fetchQuery(wishesCountQueryOptions())), ]); return ( diff --git a/apps/web/src/hooks/useGetWishesCount.ts b/apps/web/src/hooks/useGetWishesCount.ts new file mode 100644 index 000000000..39e4d2221 --- /dev/null +++ b/apps/web/src/hooks/useGetWishesCount.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; + +import { wishesCountQueryOptions } from '@/apis/getWishesCount'; +import type { GetWishesRequestT } from '@/types/wish'; + +export const useGetWishesCount = (request: GetWishesRequestT = {}, enabled = true) => { + const { data: wishesCountData } = useQuery({ ...wishesCountQueryOptions(request), enabled }); + + return { wishesCountData }; +}; From 9989c93c177af72479164c66d05a215641f644c5 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Mon, 2 Nov 2026 20:11:35 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=8B=9C=ED=8A=B8=20=EC=83=88=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=A4=91=20=EB=8B=AB=EA=B8=B0=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 폴더 만들기 요청 중에는 시트 닫기 · 뒤로가기 무시 - 원인: 닫았다 다시 연 시트를 이전 요청의 성공 처리가 목록 화면으로 되돌림 --- .../web/src/app/wish/_common/_components/FolderSelectSheet.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx index 4daba4437..da31a7a06 100644 --- a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx +++ b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx @@ -34,6 +34,7 @@ function FolderSelectSheet({ onAddFolder, }: FolderSelectSheetProps) { const handleOpenChange = (isOpen: boolean) => { + if (!isOpen && isAddPending) return; if (!isOpen) onAddViewChange(false); onOpenChange(isOpen); }; @@ -49,7 +50,7 @@ function FolderSelectSheet({ title="폴더 추가" submitErrorMessage={addErrorMessage} isPending={isAddPending} - onBack={() => onAddViewChange(false)} + onBack={() => !isAddPending && onAddViewChange(false)} onNameChange={onAddNameChange} onSubmit={onAddFolder} /> From 372cb709d0eb556b2ba7f7ac8b043fcae6ed5158 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Mon, 2 Nov 2026 20:11:35 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=9C=84=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=ED=91=9C=EC=8B=9C=EC=9D=98=20hydration=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 위시 탭 상품 개수를 Suspense 쿼리로 조회 - 원인: 일반 쿼리는 서버 렌더에서 미리 불러온 개수를 못 받아 상품 0 으로 그린 뒤 클라이언트와 어긋남 - 검색 개수는 검색 훅 안에서 제출 때만 조회 - 원인: 제출 전에는 조회하지 않아 Suspense 를 쓸 수 없음 --- .../src/app/wish/_components/WishListContent.tsx | 2 +- apps/web/src/app/wish/_components/WishSearch.tsx | 8 ++------ apps/web/src/app/wish/_hooks/useSearchWishes.ts | 15 ++++++++++++--- apps/web/src/hooks/useGetWishesCount.ts | 6 +++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/wish/_components/WishListContent.tsx b/apps/web/src/app/wish/_components/WishListContent.tsx index c8eb31e48..25be9d8a5 100644 --- a/apps/web/src/app/wish/_components/WishListContent.tsx +++ b/apps/web/src/app/wish/_components/WishListContent.tsx @@ -28,7 +28,7 @@ function WishListContent() { const searchParams = useSearchParams(); const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; - const { wishesCountData = 0 } = useGetWishesCount(); + const { wishesCountData } = useGetWishesCount(); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isTitleHidden, setIsTitleHidden] = useState(false); const { diff --git a/apps/web/src/app/wish/_components/WishSearch.tsx b/apps/web/src/app/wish/_components/WishSearch.tsx index a8cf7bab0..9741cdb55 100644 --- a/apps/web/src/app/wish/_components/WishSearch.tsx +++ b/apps/web/src/app/wish/_components/WishSearch.tsx @@ -8,7 +8,6 @@ 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 { useGetWishesCount } from '@/hooks/useGetWishesCount'; import useIntersectionObserver from '@/hooks/useIntersectionObserver'; import { cn } from '@/utils/cn'; @@ -27,6 +26,7 @@ function WishSearch({ onClose }: WishSearchProps) { const { searchWishesData, + searchWishesCountData, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -35,10 +35,6 @@ function WishSearch({ onClose }: WishSearchProps) { const bottomRef = useIntersectionObserver(fetchNextPage, hasNextPage && !isFetchingNextPage); const isSearching = isFocused || submittedKeyword === null; - const { wishesCountData: searchCount } = useGetWishesCount( - { q: submittedKeyword ?? '' }, - submittedKeyword !== null - ); const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -135,7 +131,7 @@ function WishSearch({ onClose }: WishSearchProps) { className="mx-auto flex w-full max-w-120 flex-col gap-6 pt-1 pb-10" >

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

{searchWishesData.length > 0 ? ( <> diff --git a/apps/web/src/app/wish/_hooks/useSearchWishes.ts b/apps/web/src/app/wish/_hooks/useSearchWishes.ts index 7af608948..dfccb8517 100644 --- a/apps/web/src/app/wish/_hooks/useSearchWishes.ts +++ b/apps/web/src/app/wish/_hooks/useSearchWishes.ts @@ -1,17 +1,26 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; +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({ q: keyword ?? '' }), - enabled: keyword !== null, + ...wishesInfiniteQueryOptions(request), + enabled, + }); + const { data: searchWishesCountData } = useQuery({ + ...wishesCountQueryOptions(request), + enabled, }); const searchWishesData = data?.pages.flatMap(page => page.data) ?? []; return { searchWishesData, + searchWishesCountData, fetchNextPage, hasNextPage, isFetchingNextPage, diff --git a/apps/web/src/hooks/useGetWishesCount.ts b/apps/web/src/hooks/useGetWishesCount.ts index 39e4d2221..0c0588af5 100644 --- a/apps/web/src/hooks/useGetWishesCount.ts +++ b/apps/web/src/hooks/useGetWishesCount.ts @@ -1,10 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { wishesCountQueryOptions } from '@/apis/getWishesCount'; import type { GetWishesRequestT } from '@/types/wish'; -export const useGetWishesCount = (request: GetWishesRequestT = {}, enabled = true) => { - const { data: wishesCountData } = useQuery({ ...wishesCountQueryOptions(request), enabled }); +export const useGetWishesCount = (request: GetWishesRequestT = {}) => { + const { data: wishesCountData } = useSuspenseQuery(wishesCountQueryOptions(request)); return { wishesCountData }; };