diff --git a/apps/web/e2e/helpers/apiResponse.ts b/apps/web/e2e/helpers/apiResponse.ts index 4c7f2afa..47e4361f 100644 --- a/apps/web/e2e/helpers/apiResponse.ts +++ b/apps/web/e2e/helpers/apiResponse.ts @@ -6,6 +6,10 @@ export const createApiSuccess = (data: T, status = 200): ApiResponseT => ( data, detail: '요청이 정상적으로 처리되었습니다.', code: 'COMMON_SUCCESS', + pageResponse: { + nextCursor: null, + hasNext: false, + }, }); type CreateApiErrorOptionsT = { diff --git a/apps/web/e2e/mocks/wish.ts b/apps/web/e2e/mocks/wish.ts index 8b471152..ff6d15f1 100644 --- a/apps/web/e2e/mocks/wish.ts +++ b/apps/web/e2e/mocks/wish.ts @@ -1,31 +1,26 @@ -import type { ItemT } from '@/types/item'; -import type { WishT } from '@/types/wish'; +import type { GetWishlistResponseT } from '@/types/wish'; import { MOCK_IMAGE_URLS } from './images'; import { MOCK_TOURNAMENT_ITEMS } from './tournament'; -/** getWishlist 원 응답의 data 항목 형태 (apis/getWishlist.ts 의 WishlistEntryT 와 동일) */ -type WishlistEntryT = { - wish: WishT; - item: ItemT; -}; - /** * by-wish 담기 spec 용 위시 4개. * item.id 를 토너먼트 목의 itemId 와 맞춰, 담기 후 pending.items 와 자연스럽게 이어지게 한다. */ -export const MOCK_WISHLIST_ENTRIES: WishlistEntryT[] = MOCK_TOURNAMENT_ITEMS.map( +export const MOCK_WISHLIST_ENTRIES: GetWishlistResponseT[] = MOCK_TOURNAMENT_ITEMS.map( (tournamentItem, index) => ({ wish: { id: index + 1, createdAt: '2026-01-01T00:00:00Z' }, item: { id: tournamentItem.itemId, status: 'READY', name: tournamentItem.name, - currentPrice: tournamentItem.price, + price: tournamentItem.price, currency: 'KRW', imageUrl: MOCK_IMAGE_URLS.product, sourceUrl: null, sourcePlatform: null, }, + reused: null, + refreshNeeded: null, }) ); diff --git a/apps/web/src/apis/getWishlist.ts b/apps/web/src/apis/getWishlist.ts index 2bab33df..4d758777 100644 --- a/apps/web/src/apis/getWishlist.ts +++ b/apps/web/src/apis/getWishlist.ts @@ -4,54 +4,23 @@ import { clientApi } from '@/apis/client'; import { serverApi } from '@/apis/server'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; -import type { ItemT } from '@/types/item'; -import type { WishItemT, WishT } from '@/types/wish'; +import type { GetWishlistResponseT } from '@/types/wish'; -type WishlistEntryT = { - wish: WishT; - item: ItemT; -}; - -type WishlistApiResponseT = ApiResponseT & { - pageResponse: { - nextCursor: string | null; - hasNext: boolean; - }; -}; - -export type WishlistPageT = { - items: WishItemT[]; - nextCursor: string | null; - hasNext: boolean; -}; - -const mapWishlist = (entries: WishlistEntryT[]): WishItemT[] => - entries.map(({ wish, item }) => ({ - id: wish.id, - itemId: item.id, - status: item.status, - name: item.name ?? '', - price: item.currentPrice ?? 0, - imageUrl: item.imageUrl ?? null, - sourcePlatform: item.sourcePlatform ?? null, - })); - -export const getWishlist = async (cursor: string | null = null): Promise => { +export const getWishlist = async (cursor: string | null = null) => { const params = { size: 20, ...(cursor ? { cursor } : {}) }; if (environmentManager.isServer()) { - const { data } = await serverApi.get(ENDPOINTS.WISHLISTS, { params }); - return { - items: mapWishlist(data.data), - nextCursor: data.pageResponse.nextCursor, - hasNext: data.pageResponse.hasNext, - }; + const { data } = await serverApi.get>( + ENDPOINTS.WISHLISTS, + { + params, + } + ); + return data; } - const { data } = await clientApi.get(ENDPOINTS.WISHLISTS, { params }); - return { - items: mapWishlist(data.data), - nextCursor: data.pageResponse.nextCursor, - hasNext: data.pageResponse.hasNext, - }; + const { data } = await clientApi.get>(ENDPOINTS.WISHLISTS, { + params, + }); + return data; }; diff --git a/apps/web/src/app/archive/wish/[id]/_components/EditContent.tsx b/apps/web/src/app/archive/wish/[id]/_components/EditContent.tsx index f86b5840..cd382365 100644 --- a/apps/web/src/app/archive/wish/[id]/_components/EditContent.tsx +++ b/apps/web/src/app/archive/wish/[id]/_components/EditContent.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Header, HeaderIcon } from '@/components/header'; import ItemLinkBanner from '@/components/common/item-link-banner'; +import { Header, HeaderIcon } from '@/components/header'; import { useGetWish } from '../_hooks/useGetWish'; import ItemEditForm from './ItemEditForm'; @@ -24,9 +24,9 @@ function EditContent({ wishId }: EditContentProps) { {wishData.item.sourceUrl && } diff --git a/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts b/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts index ccfac7e8..9cd03962 100644 --- a/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts +++ b/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts @@ -14,12 +14,11 @@ export const usePatchWish = (wishId: number) => { const queryClient = useQueryClient(); const { mutate: patchWishMutation, isPending: isPatchWishPending } = useMutation({ - mutationFn: (body: Omit) => { + mutationFn: (body: PatchItemRequestT) => { const formData = new FormData(); - formData.append('name', body.name); - formData.append('currentPrice', String(body.currentPrice)); - formData.append('currency', 'KRW'); - formData.append('image', body.image); + if (body.name) formData.append('name', body.name); + if (body.price) formData.append('price', String(body.price)); + if (body.image) formData.append('image', body.image); return patchWish(wishId, formData); }, onSuccess: () => { diff --git a/apps/web/src/app/archive/wish/[id]/_types/wish.ts b/apps/web/src/app/archive/wish/[id]/_types/wish.ts index 3729c588..4ae60699 100644 --- a/apps/web/src/app/archive/wish/[id]/_types/wish.ts +++ b/apps/web/src/app/archive/wish/[id]/_types/wish.ts @@ -8,7 +8,7 @@ export type GetWishResponseT = { status: (typeof ITEM_STATUS)['PROCESSING'] | (typeof ITEM_STATUS)['FAILED']; name: null; imageUrl: null; - currentPrice: null; + price: null; currency: null; sourceUrl: string | null; // 확인필 } @@ -16,11 +16,23 @@ export type GetWishResponseT = { status: (typeof ITEM_STATUS)['READY'] | (typeof ITEM_STATUS)['PENDING']; name: string; imageUrl: string; - currentPrice: number; + price: number; currency: string; sourceUrl: string | null; } ); + /** + * 갱신 필요 여부 + * - 이미지로 등록한 경우: null + * - 링크로 등록한 경우: boolean + */ + refreshNeeded: boolean | null; + /** + * 재사용 여부 + * - 이미지로 등록한 경우: null + * - 링크로 등록한 경우: boolean + */ + reused: boolean | null; }; export type PatchWishResponseT = GetWishResponseT; diff --git a/apps/web/src/app/archive/wish/_components/WishCardSkeleton.tsx b/apps/web/src/app/archive/wish/_components/WishCardSkeleton.tsx index 4bfbfda2..d0dea18e 100644 --- a/apps/web/src/app/archive/wish/_components/WishCardSkeleton.tsx +++ b/apps/web/src/app/archive/wish/_components/WishCardSkeleton.tsx @@ -2,11 +2,14 @@ import Skeleton from '@/components/skeleton'; function WishCardSkeleton() { return ( -
- -
- - +
+ +
+
+ + +
+
); diff --git a/apps/web/src/app/archive/wish/_components/WishContent.tsx b/apps/web/src/app/archive/wish/_components/WishContent.tsx index 7f7e5980..09fce7e0 100644 --- a/apps/web/src/app/archive/wish/_components/WishContent.tsx +++ b/apps/web/src/app/archive/wish/_components/WishContent.tsx @@ -1,7 +1,8 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import type { WishlistPageT } from '@/apis/getWishlist'; import { getWishlist } from '@/apis/getWishlist'; +import type { ApiResponseT } from '@/types/api'; +import type { GetWishlistResponseT } from '@/types/wish'; import { getQueryClient } from '@/utils/queryClient'; import WishContentClient from './WishContentClient'; @@ -11,9 +12,10 @@ async function WishContent() { await queryClient.prefetchInfiniteQuery({ queryKey: ['wishlists'], - queryFn: ({ pageParam }) => getWishlist(pageParam as string | null), + queryFn: ({ pageParam }) => getWishlist(pageParam), initialPageParam: null as string | null, - getNextPageParam: (page: WishlistPageT) => (page.hasNext ? page.nextCursor : null), + getNextPageParam: (page: ApiResponseT) => + page.pageResponse.hasNext ? page.pageResponse.nextCursor : null, }); return ( diff --git a/apps/web/src/app/archive/wish/_components/WishContentClient.tsx b/apps/web/src/app/archive/wish/_components/WishContentClient.tsx index 5d891e52..f35ec1b1 100644 --- a/apps/web/src/app/archive/wish/_components/WishContentClient.tsx +++ b/apps/web/src/app/archive/wish/_components/WishContentClient.tsx @@ -38,10 +38,10 @@ function WishContentClient() { const { wishlistData } = useGetWishlist(); const selectableIds = wishlistData .filter( - item => + ({ item }) => item.status !== 'FAILED' && item.status !== 'PENDING' && item.status !== 'PROCESSING' ) - .map(item => item.id); + .map(({ wish }) => wish.id); const isAllSelected = selectableIds.length > 0 && selectableIds.every(id => selectedIds.has(id)); diff --git a/apps/web/src/app/archive/wish/_components/WishGridContent.tsx b/apps/web/src/app/archive/wish/_components/WishGridContent.tsx index f89bc044..ec35d01f 100644 --- a/apps/web/src/app/archive/wish/_components/WishGridContent.tsx +++ b/apps/web/src/app/archive/wish/_components/WishGridContent.tsx @@ -1,10 +1,10 @@ import { HeartIconFill } from '@/assets/icons'; -import type { WishItemT } from '@/types/wish'; +import type { GetWishlistResponseT } from '@/types/wish'; import WishGrid from './wish-grid'; type WishGridContentProps = { - items: WishItemT[]; + items: GetWishlistResponseT[]; isDeleteMode?: boolean; selectedIds?: Set; onToggleSelect?: (id: number) => void; diff --git a/apps/web/src/app/archive/wish/_components/WishlistList.tsx b/apps/web/src/app/archive/wish/_components/WishlistList.tsx index 91c07817..ea418e2b 100644 --- a/apps/web/src/app/archive/wish/_components/WishlistList.tsx +++ b/apps/web/src/app/archive/wish/_components/WishlistList.tsx @@ -20,7 +20,7 @@ function WishlistList({ isDeleteMode, selectedIds, onToggleSelect }: WishlistLis const sentinelRef = useRef(null); const { wishlistData, fetchNextPage, hasNextPage, isFetchingNextPage } = useGetWishlist(); - const hasPendingItem = hasParsingItems(wishlistData); + const hasPendingItem = hasParsingItems(wishlistData.map(({ item }) => item)); useSSEFallback(['wishlists'], hasPendingItem); useScrollRestoration(); diff --git a/apps/web/src/app/archive/wish/_components/wish-grid/index.tsx b/apps/web/src/app/archive/wish/_components/wish-grid/index.tsx index 2ab7982c..747975b4 100644 --- a/apps/web/src/app/archive/wish/_components/wish-grid/index.tsx +++ b/apps/web/src/app/archive/wish/_components/wish-grid/index.tsx @@ -5,14 +5,14 @@ import { CheckboxEmptyIconFill, CheckboxSelectedIconFill } from '@/assets/icons' import WishCard from '@/components/common/wish-card'; import { ROUTES } from '@/consts/route'; import { Z_INDEX } from '@/consts/zIndex'; -import type { WishItemT } from '@/types/wish'; +import type { GetWishlistResponseT } from '@/types/wish'; import { saveWishScroll } from '../../_utils/wishScroll'; import WishFailedCard from './WishFailedCard'; import WishProcessingCard from './WishProcessingCard'; type WishGridProps = { - items: WishItemT[]; + items: GetWishlistResponseT[]; isDeleteMode?: boolean; selectedIds?: Set; onToggleSelect?: (id: number) => void; @@ -28,28 +28,28 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: return (
- {items.map((item, index) => { + {items.map(({ wish, item }, index) => { if (item.status === 'FAILED') return ( handleCardClick(event, item.id)} + href={ROUTES.WISH_EDIT(wish.id)} + key={wish.id} + data-wish-id={wish.id} + onClick={event => handleCardClick(event, wish.id)} > - + ); else if (item.status === 'PENDING' || item.status === 'PROCESSING') - return ; + return ; if (isDeleteMode) { - const isSelected = selectedIds?.has(item.id) ?? false; + const isSelected = selectedIds?.has(wish.id) ?? false; return ( + + + + {/** 정보 갱신은 READY만 가능 */} + {itemStatus === 'READY' && !!onRefresh && ( - - )} - - {itemStatus === 'FAILED' && ( - - - - - )} + )} + + + ); } diff --git a/apps/web/src/hooks/useGetWishlist.ts b/apps/web/src/hooks/useGetWishlist.ts index bd4ae493..5d717ba3 100644 --- a/apps/web/src/hooks/useGetWishlist.ts +++ b/apps/web/src/hooks/useGetWishlist.ts @@ -7,10 +7,10 @@ export const useGetWishlist = () => { queryKey: ['wishlists'], queryFn: ({ pageParam }) => getWishlist(pageParam), initialPageParam: null as string | null, - getNextPageParam: page => (page.hasNext ? page.nextCursor : null), + getNextPageParam: page => (page.pageResponse.hasNext ? page.pageResponse.nextCursor : null), }); - const wishlistData = data.pages.flatMap(page => page.items); + const wishlistData = data.pages.flatMap(page => page.data); return { wishlistData, fetchNextPage, hasNextPage, isFetchingNextPage }; }; diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index bc67bb56..e136be71 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -4,6 +4,10 @@ export type ApiResponseT = { data: T; detail: string; code: string; + pageResponse: { + nextCursor: string | null; + hasNext: boolean; + }; }; // 공통 에러 응답 타입 diff --git a/apps/web/src/types/item.ts b/apps/web/src/types/item.ts index 8f4eff31..5bb7d418 100644 --- a/apps/web/src/types/item.ts +++ b/apps/web/src/types/item.ts @@ -6,7 +6,7 @@ export type ItemT = { id: number; status: ItemStatusT; name: string; - currentPrice: number; + price: number; currency: string | null; imageUrl: string | null; sourceUrl: string | null; @@ -16,8 +16,9 @@ export type ItemT = { export type ItemStatusT = (typeof ITEM_STATUS)[keyof typeof ITEM_STATUS]; export type PatchItemRequestT = { - name: string; - currentPrice: number; - image: File; - currency: string; + name?: string; + price?: number; + image?: File; + /** NOTE: currency는 optional이지만, 사용하지 않는 필드이므로 삭제함. 추후 필요할 때 다시 추가할 수 있음 */ + // currency?: string; }; diff --git a/apps/web/src/types/wish.ts b/apps/web/src/types/wish.ts index 8c92f194..e157d29c 100644 --- a/apps/web/src/types/wish.ts +++ b/apps/web/src/types/wish.ts @@ -1,26 +1,37 @@ -import type { ItemStatusT, ItemT } from './item'; +import type { ItemT } from './item'; -export type PostWishOCRResponseT = { +export type WishT = { + id: number; + createdAt: string; +}; + +export type GetWishlistResponseT = { wish: WishT; item: ItemT; + /** + * 갱신 필요 여부 + * - 이미지로 등록한 경우: null + * - 링크로 등록한 경우: boolean + */ + refreshNeeded: boolean | null; + /** + * 재사용 여부 + * - 이미지로 등록한 경우: null + * - 링크로 등록한 경우: boolean + */ + reused: boolean | null; }; export type PostWishLinkResponseT = { wish: WishT; item: ItemT; + refreshNeeded: boolean; + reused: boolean; }; -export type WishT = { - id: number; - createdAt: string; -}; - -export type WishItemT = { - id: number; - itemId: number; - name: string; - price: number; - imageUrl: string | null; - status: ItemStatusT; - sourcePlatform: string | null; +export type PostWishOCRResponseT = { + wish: WishT; + item: ItemT; + refreshNeeded: null; + reused: null; };