diff --git a/apps/web/e2e/specs/wish/folderDetail.spec.ts b/apps/web/e2e/specs/wish/folderDetail.spec.ts new file mode 100644 index 00000000..3a1a684e --- /dev/null +++ b/apps/web/e2e/specs/wish/folderDetail.spec.ts @@ -0,0 +1,216 @@ +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, MOCK_WISH_ADDED_BY_LINK } 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; +const OTHER_FOLDER = MOCK_FOLDERS.find(({ id }) => id !== MOCK_FOLDER.id); +const FOLDER_PATH = `/wish/folder/${MOCK_FOLDER.id}`; + +test('폴더 상세에 폴더 이름 · 상품 개수 · 위시가 보인다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + + await page.goto(FOLDER_PATH); + + await expect(page.getByRole('heading', { name: MOCK_FOLDER.name })).toBeVisible(); + await expect(page.getByText(`상품 ${MOCK_FOLDER.wishCount}`)).toBeVisible(); + await expect(page.getByText(FIRST_WISH_NAME, { exact: true })).toBeVisible(); +}); + +test('없는 폴더로 들어오면 안내 후 폴더 탭으로 이동한다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS); + + await page.goto('/wish/folder/999'); + + await expect(page).toHaveURL(/\/wish\?tab=folder$/); + await expect(page.getByText('폴더를 찾을 수 없어요.')).toBeVisible(); +}); + +test('편집 모드에서 고른 위시를 폴더에서 뺀다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.delete(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 6 }); + 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(FOLDER_PATH); + 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 removeRequest = page.waitForRequest( + request => + request.method() === 'DELETE' && + new URL(request.url()).pathname === ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id) + ); + await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click(); + + expect(new URL((await removeRequest).url()).searchParams.getAll('wishIds')).toEqual([ + 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_MOVE(MOCK_FOLDER.id), { + folderId: MOCK_FOLDER.id, + wishCount: 6, + targetFolderId: OTHER_FOLDER?.id, + targetWishCount: 1, + }); + 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(FOLDER_PATH); + await page.getByRole('button', { name: '편집' }).click(); + await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click(); + await page.getByRole('button', { name: '폴더 이동' }).click(); + + const moveRequest = page.waitForRequest( + request => + request.method() === 'POST' && + request.url().endsWith(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id)) + ); + await page.getByRole('button', { name: new RegExp(OTHER_FOLDER?.name ?? '') }).click(); + + expect((await moveRequest).postDataJSON()).toEqual({ + wishIds: [FIRST_WISH_ID], + targetFolderId: OTHER_FOLDER?.id, + }); + await expect(page.getByText(`상품을 '${OTHER_FOLDER?.name}' 폴더로 이동했어요.`)).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(`${FOLDER_PATH}/add`); + await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click(); + + const addRequest = page.waitForRequest( + request => + request.method() === 'POST' && request.url().endsWith(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id)) + ); + await page.getByRole('button', { name: '선택한 1개의 상품 추가' }).click(); + + expect((await addRequest).postDataJSON()).toEqual({ wishIds: [FIRST_WISH_ID] }); + await expect(page).toHaveURL(new RegExp(`${FOLDER_PATH}$`)); +}); + +test('폴더 상세에서 링크로 담으면 폴더를 지정해 등록하고 화면에 머문다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.post(ENDPOINTS.WISHLISTS, MOCK_WISH_ADDED_BY_LINK); + 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(FOLDER_PATH); + await page.getByRole('button', { name: '추가', exact: true }).click(); + await page.getByRole('button', { name: /링크로 담기/ }).click(); + await page.getByLabel('링크 URL').fill('https://shop.example/items/105'); + + const registerRequest = page.waitForRequest( + request => request.method() === 'POST' && request.url().endsWith(ENDPOINTS.WISHLISTS) + ); + await page.getByRole('button', { name: '위시리스트에 담기' }).click(); + + expect((await registerRequest).postDataJSON()).toEqual({ + url: 'https://shop.example/items/105', + folderId: MOCK_FOLDER.id, + }); + await expect(page.getByRole('dialog', { name: '링크로 담기' })).toBeHidden(); + await expect(page).toHaveURL(new RegExp(`${FOLDER_PATH}$`)); +}); + +test('전체 선택 후 빼면 불러오지 않은 위시까지 all 로 뺀다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.delete(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 0 }); + 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(FOLDER_PATH); + await page.getByRole('button', { name: '편집' }).click(); + await page.getByRole('button', { name: '전체 선택' }).click(); + await page.getByRole('button', { name: '삭제', exact: true }).click(); + + await expect( + page.getByRole('dialog', { name: `선택한 상품 ${MOCK_FOLDER.wishCount}개를 삭제할까요?` }) + ).toBeVisible(); + + const removeRequest = page.waitForRequest( + request => + request.method() === 'DELETE' && + new URL(request.url()).pathname === ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id) + ); + await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click(); + + const searchParams = new URL((await removeRequest).url()).searchParams; + expect(searchParams.get('all')).toBe('true'); + expect(searchParams.getAll('wishIds')).toEqual([]); +}); + +test('전체 선택 후 다른 폴더로 옮기면 all 로 옮긴다', async ({ page, api }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.post(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id), { + folderId: MOCK_FOLDER.id, + wishCount: 0, + targetFolderId: OTHER_FOLDER?.id, + targetWishCount: MOCK_FOLDER.wishCount, + }); + 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(FOLDER_PATH); + await page.getByRole('button', { name: '편집' }).click(); + await page.getByRole('button', { name: '전체 선택' }).click(); + await page.getByRole('button', { name: '폴더 이동' }).click(); + + const moveRequest = page.waitForRequest( + request => + request.method() === 'POST' && + request.url().endsWith(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id)) + ); + await page.getByRole('button', { name: new RegExp(OTHER_FOLDER?.name ?? '') }).click(); + + expect((await moveRequest).postDataJSON()).toEqual({ + all: true, + targetFolderId: OTHER_FOLDER?.id, + }); +}); + +for (const { folderResult, message } of [ + { folderResult: 'ADDED', message: '이미 담은 상품을 폴더에 추가했어요.' }, + { folderResult: 'ALREADY_IN_FOLDER', message: '이미 이 폴더에 있는 상품이에요.' }, +] as const) { + test(`이미 담은 링크를 폴더에서 담으면 ${folderResult} 결과를 안내한다`, async ({ + page, + api, + }) => { + api.get(ENDPOINTS.USER, MOCK_MEMBER_ME); + api.post(ENDPOINTS.WISHLISTS, { ...MOCK_WISH_ADDED_BY_LINK, folderResult }); + 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(FOLDER_PATH); + await page.getByRole('button', { name: '추가', exact: true }).click(); + await page.getByRole('button', { name: /링크로 담기/ }).click(); + await page.getByLabel('링크 URL').fill('https://shop.example/items/105'); + await page.getByRole('button', { name: '위시리스트에 담기' }).click(); + + await expect(page.getByText(message)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`${FOLDER_PATH}$`)); + }); +} diff --git a/apps/web/src/apis/postWishLink.ts b/apps/web/src/apis/postWishLink.ts index cdcc7b41..13cb9853 100644 --- a/apps/web/src/apis/postWishLink.ts +++ b/apps/web/src/apis/postWishLink.ts @@ -7,11 +7,12 @@ import { clientApi } from './client'; /** * @param url 등록할 상품 링크 * @param isExternalShare 앱공유 유입 여부 + * @param folderId 등록과 동시에 넣을 폴더 */ -export const postWishLink = async (url: string, isExternalShare = false) => { +export const postWishLink = async (url: string, isExternalShare = false, folderId?: number) => { const { data } = await clientApi.post>( ENDPOINTS.WISHLISTS, - { url }, + { url, ...(typeof folderId === 'number' && { folderId }) }, isExternalShare ? { headers: { 'X-Client-Entry-Point': 'EXTERNAL_SHARE' } } : {} ); diff --git a/apps/web/src/app/wish/_common/_consts/folder.ts b/apps/web/src/app/wish/_common/_consts/folder.ts index 7846c554..298d1367 100644 --- a/apps/web/src/app/wish/_common/_consts/folder.ts +++ b/apps/web/src/app/wish/_common/_consts/folder.ts @@ -1,6 +1,8 @@ import { ERROR_CODE } from '@piki/core'; import type { ApiErrorCodeT } from '@piki/core'; +export const MAX_FOLDER_WISH_SELECT_COUNT = 100; + export const FOLDER_NAME_ERROR_CODES: readonly (ApiErrorCodeT | null)[] = [ ERROR_CODE.FOLDER_INVALID_NAME, ]; diff --git a/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts b/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts index 8b44f8b0..be5ab357 100644 --- a/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts +++ b/apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts @@ -13,7 +13,11 @@ type PostFolderWishesVariablesT = { wishIds: number[]; }; -export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } = {}) => { +type UsePostFolderWishesOptionsT = { + onSuccess?: (folder: PostFolderWishesVariablesT['folder']) => void; +}; + +export const usePostFolderWishes = ({ onSuccess }: UsePostFolderWishesOptionsT = {}) => { const queryClient = useQueryClient(); const { mutate: postFolderWishesMutation, isPending: isPostFolderWishesPending } = useMutation({ @@ -22,8 +26,7 @@ export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } = onSuccess: (_, { folder }) => { queryClient.invalidateQueries({ queryKey: ['wishlists'] }); queryClient.invalidateQueries({ queryKey: ['folders'] }); - toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`); - onSuccess?.(); + onSuccess?.(folder); }, onError: error => { if (isGlobalNetError(error)) return; diff --git a/apps/web/src/app/wish/_common/_utils/formatWishCount.ts b/apps/web/src/app/wish/_common/_utils/formatWishCount.ts new file mode 100644 index 00000000..ef0ce746 --- /dev/null +++ b/apps/web/src/app/wish/_common/_utils/formatWishCount.ts @@ -0,0 +1,4 @@ +const MAX_DISPLAY_COUNT = 999; + +export const formatWishCount = (count: number) => + count > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : String(count); diff --git a/apps/web/src/app/wish/_components/WishListContent.tsx b/apps/web/src/app/wish/_components/WishListContent.tsx index 25be9d8a..62b57102 100644 --- a/apps/web/src/app/wish/_components/WishListContent.tsx +++ b/apps/web/src/app/wish/_components/WishListContent.tsx @@ -2,6 +2,7 @@ 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'; @@ -40,7 +41,12 @@ function WishListContent() { handleExitEditMode, } = useWishEditMode(); - const { postFolderWishesMutation } = usePostFolderWishes({ onSuccess: handleClearSelection }); + const { postFolderWishesMutation } = usePostFolderWishes({ + onSuccess: folder => { + handleClearSelection(); + toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`); + }, + }); const { deleteWishesMutation } = useDeleteWishes({ onSuccess: handleClearSelection }); /** 전체 위시에서는 기존 소속 폴더를 유지한 채 대상 폴더에 추가 */ diff --git a/apps/web/src/app/wish/_components/WishTabSummary.tsx b/apps/web/src/app/wish/_components/WishTabSummary.tsx index 90e7904c..e6a72ff3 100644 --- a/apps/web/src/app/wish/_components/WishTabSummary.tsx +++ b/apps/web/src/app/wish/_components/WishTabSummary.tsx @@ -7,11 +7,10 @@ import { useGetFolders } from '@/hooks/useGetFolders'; import { WISH_MODE } from '../_common/_consts/wishMode'; import type { WishModeT } from '../_common/_types/wishMode'; +import { formatWishCount } from '../_common/_utils/formatWishCount'; import { WISH_TAB } from '../_consts/wishTab'; import { parseWishTabParam } from '../_utils/wishSearchParams'; -const MAX_DISPLAY_COUNT = 999; - type WishTabSummaryProps = { wishCount: number; mode: WishModeT; @@ -36,9 +35,7 @@ function WishTabSummary({ wishCount, mode, onEdit, onSearch }: WishTabSummaryPro return (
-

- 상품 {wishCount > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishCount} -

+

상품 {formatWishCount(wishCount)}

{mode === WISH_MODE.DEFAULT && (
} />

- 상품 {candidates.length > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : candidates.length} + 상품 {formatWishCount(wishesCountData)}

+
- @@ -83,4 +97,22 @@ function FolderWishSelectContent({ folderId }: FolderWishSelectContentProps) { ); } +function FolderWishSelectContent({ folderId }: FolderWishSelectContentProps) { + const router = useRouter(); + const { foldersData } = useGetFolders(); + + const folder = foldersData.find(({ id }) => id === folderId); + + useEffect(() => { + if (folder) return; + + toast.error('폴더를 찾을 수 없어요.'); + router.replace(ROUTES.WISHLIST_FOLDER_TAB); + }, [folder, router]); + + if (!folder) return null; + + return ; +} + export default FolderWishSelectContent; diff --git a/apps/web/src/app/wish/folder/[folderId]/add/page.tsx b/apps/web/src/app/wish/folder/[folderId]/add/page.tsx index dbc427a5..bd0d5085 100644 --- a/apps/web/src/app/wish/folder/[folderId]/add/page.tsx +++ b/apps/web/src/app/wish/folder/[folderId]/add/page.tsx @@ -1,4 +1,11 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; + +import { foldersQueryOptions } from '@/apis/getFolders'; +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; +import { wishesCountQueryOptions } from '@/apis/getWishesCount'; import { parseIdParam } from '@/utils/parseIdParam'; +import { getQueryClient } from '@/utils/queryClient'; +import { serverPrefetch } from '@/utils/serverPrefetch'; import FolderWishSelectContent from './_components/FolderWishSelectContent'; @@ -7,12 +14,27 @@ type FolderWishAddPageProps = { }; async function FolderWishAddPage({ params }: FolderWishAddPageProps) { - const { folderId } = await params; + const folderId = parseIdParam((await params).folderId); + const queryClient = getQueryClient(); + + await Promise.all([ + serverPrefetch(() => queryClient.fetchQuery(foldersQueryOptions)), + folderId !== null && + serverPrefetch(() => + queryClient.fetchInfiniteQuery(wishesInfiniteQueryOptions({ excludeFolderId: folderId })) + ), + folderId !== null && + serverPrefetch(() => + queryClient.fetchQuery(wishesCountQueryOptions({ excludeFolderId: folderId })) + ), + ]); return ( -
- -
+ +
+ +
+
); } diff --git a/apps/web/src/app/wish/folder/[folderId]/page.tsx b/apps/web/src/app/wish/folder/[folderId]/page.tsx index cc7c79e5..bdbc1873 100644 --- a/apps/web/src/app/wish/folder/[folderId]/page.tsx +++ b/apps/web/src/app/wish/folder/[folderId]/page.tsx @@ -1,4 +1,10 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; + +import { foldersQueryOptions } from '@/apis/getFolders'; +import { wishesInfiniteQueryOptions } from '@/apis/getWishes'; import { parseIdParam } from '@/utils/parseIdParam'; +import { getQueryClient } from '@/utils/queryClient'; +import { serverPrefetch } from '@/utils/serverPrefetch'; import FolderDetailContent from './_components/FolderDetailContent'; @@ -7,12 +13,23 @@ type FolderDetailPageProps = { }; async function FolderDetailPage({ params }: FolderDetailPageProps) { - const { folderId } = await params; + const folderId = parseIdParam((await params).folderId); + const queryClient = getQueryClient(); + + await Promise.all([ + serverPrefetch(() => queryClient.fetchQuery(foldersQueryOptions)), + folderId !== null && + serverPrefetch(() => + queryClient.fetchInfiniteQuery(wishesInfiniteQueryOptions({ folderId })) + ), + ]); return ( -
- -
+ +
+ +
+
); } diff --git a/apps/web/src/components/get-item-dialog/ByImageDialog.tsx b/apps/web/src/components/get-item-dialog/ByImageDialog.tsx index 9d1cf018..80fd620a 100644 --- a/apps/web/src/components/get-item-dialog/ByImageDialog.tsx +++ b/apps/web/src/components/get-item-dialog/ByImageDialog.tsx @@ -49,11 +49,14 @@ type Props = { type: ItemTypeT; open: boolean; onOpenChange: (open: boolean) => void; + folderId?: number; }; -function ByImageDialog({ type, open, onOpenChange }: Props) { +function ByImageDialog({ type, open, onOpenChange, folderId }: Props) { const { id: tournamentId } = useParams<{ id: string }>(); - const { postWishOCRMutation, isPostWishOCRPending, resetPostWishOCRMutation } = usePostWishOCR(); + const { postWishOCRMutation, isPostWishOCRPending, resetPostWishOCRMutation } = usePostWishOCR( + typeof folderId === 'number' ? { folderId } : {} + ); const { postTournamentOCRMutation, isPostTournamentOCRPending, resetPostTournamentOCRMutation } = usePostTournamentOCR(Number(tournamentId)); diff --git a/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx b/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx index 8833b6e2..6107e178 100644 --- a/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx +++ b/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx @@ -18,9 +18,10 @@ type ByLinkProps = { type: ItemTypeT; open: boolean; onOpenChange: (open: boolean) => void; + folderId?: number; }; -function ByLinkDialog({ type, open, onOpenChange }: ByLinkProps) { +function ByLinkDialog({ type, open, onOpenChange, folderId }: ByLinkProps) { const { id: tournamentId } = useParams<{ id: string }>(); const [url, setUrl] = useState(''); @@ -28,6 +29,7 @@ function ByLinkDialog({ type, open, onOpenChange }: ByLinkProps) { const { postWishLinkMutation, isPostWishLinkPending } = usePostWishLink({ onErrorMessage: setErrorMessage, + ...(typeof folderId === 'number' && { folderId }), }); const { postTournamentItemLinkMutation, isPostTournamentItemLinkPending } = usePostTournamentItemLink(Number(tournamentId), { onErrorMessage: setErrorMessage }); diff --git a/apps/web/src/consts/folder.ts b/apps/web/src/consts/folder.ts new file mode 100644 index 00000000..c4239253 --- /dev/null +++ b/apps/web/src/consts/folder.ts @@ -0,0 +1,9 @@ +export const FOLDER_RESULT = { + ADDED: 'ADDED', + ALREADY_IN_FOLDER: 'ALREADY_IN_FOLDER', +} as const; + +export const FOLDER_RESULT_MESSAGE = { + [FOLDER_RESULT.ADDED]: '이미 담은 상품을 폴더에 추가했어요.', + [FOLDER_RESULT.ALREADY_IN_FOLDER]: '이미 이 폴더에 있는 상품이에요.', +} as const; diff --git a/apps/web/src/hooks/usePostWishLink.ts b/apps/web/src/hooks/usePostWishLink.ts index 8e608f7b..0c0174b9 100644 --- a/apps/web/src/hooks/usePostWishLink.ts +++ b/apps/web/src/hooks/usePostWishLink.ts @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { postWishLink } from '@/apis/postWishLink'; import { TOAST_ACTION_DURATION_MS } from '@/components/toast/toast.const'; import { ANALYTICS_EVENT } from '@/consts/analytics'; +import { FOLDER_RESULT_MESSAGE } from '@/consts/folder'; import { ROUTES } from '@/consts/route'; import { logAnalyticsEvent } from '@/utils/analytics'; import { @@ -22,11 +23,14 @@ type UsePostWishLinkOptionsT = { isExternalShare?: boolean; /** 4xx 문구를 그릴 곳 — 입력 폼처럼 화면 안에서 안내할 때 넘긴다. 생략하면 토스트 */ onErrorMessage?: (message: string) => void; + /** 주면 등록과 함께 폴더에 넣고 현재 화면에 머문다 */ + folderId?: number; }; export const usePostWishLink = ({ isExternalShare, onErrorMessage, + folderId, }: UsePostWishLinkOptionsT = {}) => { const showErrorMessage = onErrorMessage ?? toast.error; @@ -39,10 +43,16 @@ export const usePostWishLink = ({ isPending: isPostWishLinkPending, reset: resetPostWishLinkMutation, } = useMutation({ - mutationFn: (url: string) => postWishLink(url, isExternalShare), - onSuccess: () => { - logAnalyticsEvent(ANALYTICS_EVENT.WISH_ADD_COMPLETE, { source: 'link' }); + mutationFn: (url: string) => postWishLink(url, isExternalShare, folderId), + onSuccess: ({ folderResult }) => { + if (!folderResult) logAnalyticsEvent(ANALYTICS_EVENT.WISH_ADD_COMPLETE, { source: 'link' }); queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + + if (typeof folderId === 'number') { + queryClient.invalidateQueries({ queryKey: ['folders'] }); + if (folderResult) toast(FOLDER_RESULT_MESSAGE[folderResult]); + return; + } if (pathname !== ROUTES.WISHLIST) router.push(ROUTES.WISHLIST); }, onError: error => { diff --git a/apps/web/src/hooks/usePostWishOCR.ts b/apps/web/src/hooks/usePostWishOCR.ts index 269508af..51f1a1e0 100644 --- a/apps/web/src/hooks/usePostWishOCR.ts +++ b/apps/web/src/hooks/usePostWishOCR.ts @@ -12,7 +12,7 @@ import { createS3UploadError, getApiErrorStatus, isGlobalNetError } from '@/util import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; import { getLoginPath } from '@/utils/loginRedirect'; -export const usePostWishOCR = () => { +export const usePostWishOCR = ({ folderId }: { folderId?: number } = {}) => { const router = useRouter(); const pathname = usePathname(); const queryClient = useQueryClient(); @@ -39,11 +39,19 @@ export const usePostWishOCR = () => { }) ); - return postWishImages({ imageKeys: uploads.map(upload => upload.imageKey) }); + return postWishImages({ + imageKeys: uploads.map(upload => upload.imageKey), + ...(typeof folderId === 'number' && { folderId }), + }); }, onSuccess: () => { logAnalyticsEvent(ANALYTICS_EVENT.WISH_ADD_COMPLETE, { source: 'ocr' }); queryClient.invalidateQueries({ queryKey: ['wishlists'] }); + + if (typeof folderId === 'number') { + queryClient.invalidateQueries({ queryKey: ['folders'] }); + return; + } if (pathname !== ROUTES.WISHLIST) router.push(ROUTES.WISHLIST); }, onError: error => { diff --git a/apps/web/src/types/folder.ts b/apps/web/src/types/folder.ts index 82d292f8..3d8dddf6 100644 --- a/apps/web/src/types/folder.ts +++ b/apps/web/src/types/folder.ts @@ -1,3 +1,5 @@ +import type { FOLDER_RESULT } from '@/consts/folder'; + export type FolderT = { id: number; name: string; @@ -45,3 +47,5 @@ export type PostFolderWishesMoveResponseT = { targetFolderId: number; targetWishCount: number; }; + +export type FolderResultT = (typeof FOLDER_RESULT)[keyof typeof FOLDER_RESULT]; diff --git a/apps/web/src/types/wish.ts b/apps/web/src/types/wish.ts index f67e571a..5a2b0abc 100644 --- a/apps/web/src/types/wish.ts +++ b/apps/web/src/types/wish.ts @@ -1,3 +1,4 @@ +import type { FolderResultT } from './folder'; import type { PresignedImageRequestT, PresignedImageResponseT } from './image'; import type { ItemT } from './item'; @@ -37,6 +38,8 @@ export type WishListItemT = Omit & { export type PostWishLinkResponseT = { wish: WishT; item: ItemT; + /** folderId 로 등록했을 때 이미 담은 링크였는지. 새 위시면 null */ + folderResult?: FolderResultT | null; }; export type PostWishPresignedUrlRequestT = { @@ -49,6 +52,7 @@ export type PostWishPresignedUrlResponseT = { export type PostWishImagesRequestT = { imageKeys: string[]; + folderId?: number; }; export type PostWishImagesResponseT = {