Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/e2e/mocks/tournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const MOCK_TOURNAMENT_PENDING: GetTournamentPendingResponseT = {
inviteCode: 'E2ECODE',
/** 미래 시각으로 고정해 초대 만료 카운트다운이 항상 유효하도록 */
inviteExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
/** status 가 PENDING/PROCESSING 인 아이템을 두지 않아 SSE 폴백 폴링이 돌지 않게 함 */
/** status 가 PARSING 인 아이템을 두지 않아 SSE 폴백 폴링이 돌지 않게 함 */
items: [],
participants: [
{
Expand Down
14 changes: 9 additions & 5 deletions apps/web/e2e/mocks/wish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export const MOCK_WISHLIST_ENTRIES: GetWishlistResponseT[] = MOCK_TOURNAMENT_ITE
imageUrl: MOCK_IMAGE_URLS.product,
sourceUrl: null,
sourcePlatform: null,
method: 'STRUCTURED',
userInput: false,
refreshing: false,
},
reused: null,
refreshNeeded: null,
winnerTournamentIds: [],
})
);
Expand All @@ -41,7 +42,9 @@ export const MOCK_WISH_DETAIL: GetWishResponseT = {
imageUrl: MOCK_IMAGE_URLS.product,
sourceUrl: MOCK_WISH_SOURCE_URL,
sourcePlatform: 'E2E몰',
source: 'SERVER',
method: 'STRUCTURED',
userInput: false,
refreshing: false,
},
priceHistory: [],
};
Expand All @@ -57,7 +60,8 @@ export const MOCK_WISH_ADDED_BY_LINK: PostWishLinkResponseT = {
imageUrl: MOCK_IMAGE_URLS.product,
sourceUrl: 'https://shop.example/items/105',
sourcePlatform: 'E2E몰',
method: 'STRUCTURED',
userInput: false,
refreshing: false,
},
refreshNeeded: false,
reused: false,
};
12 changes: 3 additions & 9 deletions apps/web/e2e/specs/archive/wishAddByImage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,7 @@ test('여러 장을 고르면 에디터가 열리고, 편집한 장만 JPEG 로
{ imageKey: 'key-2', uploadUrl: `${MOCK_UPLOAD_URL_PREFIX}2`, contentType: 'image/svg+xml' },
],
});
api.post(ENDPOINTS.WISH_IMAGE_CONFIRM, [
{ ...MOCK_WISH_ADDED_BY_LINK, refreshNeeded: null, reused: null },
]);
api.post(ENDPOINTS.WISH_IMAGE_CONFIRM, [MOCK_WISH_ADDED_BY_LINK]);

const putBodies: Record<string, { contentType?: string; body?: Buffer | null }> = {};
await page.route(`${MOCK_UPLOAD_URL_PREFIX}*`, async route => {
Expand Down Expand Up @@ -167,9 +165,7 @@ test('크롭 박스 모서리를 끌어 줄이면 그 영역만 JPEG 로 업로
{ imageKey: 'key-1', uploadUrl: `${MOCK_UPLOAD_URL_PREFIX}1`, contentType: 'image/jpeg' },
],
});
api.post(ENDPOINTS.WISH_IMAGE_CONFIRM, [
{ ...MOCK_WISH_ADDED_BY_LINK, refreshNeeded: null, reused: null },
]);
api.post(ENDPOINTS.WISH_IMAGE_CONFIRM, [MOCK_WISH_ADDED_BY_LINK]);

let putBody: Buffer | null | undefined;
await page.route(`${MOCK_UPLOAD_URL_PREFIX}*`, async route => {
Expand Down Expand Up @@ -236,9 +232,7 @@ test('브라우저가 디코드하지 못하는 이미지는 편집 불가 안
{ imageKey: 'key-1', uploadUrl: `${MOCK_UPLOAD_URL_PREFIX}1`, contentType: 'image/heic' },
],
});
api.post(ENDPOINTS.WISH_IMAGE_CONFIRM, [
{ ...MOCK_WISH_ADDED_BY_LINK, refreshNeeded: null, reused: null },
]);
api.post(ENDPOINTS.WISH_IMAGE_CONFIRM, [MOCK_WISH_ADDED_BY_LINK]);
await page.route(`${MOCK_UPLOAD_URL_PREFIX}*`, route => route.fulfill({ status: 200, body: '' }));

const presignRequestPromise = page.waitForRequest(
Expand Down
2 changes: 0 additions & 2 deletions apps/web/e2e/specs/archive/wishDetail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ test('상세 정보를 확인하고 상품명을 수정해 저장하면 조회
api.patch(ENDPOINTS.WISHLIST(WISH_ID), {
wish: MOCK_WISH_DETAIL.wish,
item: updatedItem,
refreshNeeded: null,
reused: null,
});
api.get(ENDPOINTS.WISHLIST(WISH_ID), { ...MOCK_WISH_DETAIL, item: updatedItem });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ function WishInfoScreen({ wishId }: WishInfoScreenProps) {
const { item } = wishData;

const {
wishItem,
refreshWish,
isPostWishRefreshPending,
isPostWishRefreshFailed,
Expand All @@ -32,11 +31,11 @@ function WishInfoScreen({ wishId }: WishInfoScreenProps) {
<ItemInfoScreen
itemType="wish"
item={{
status: wishItem.status,
imageUrl: wishItem.imageUrl,
name: wishItem.name,
price: wishItem.price,
sourceUrl: wishItem.sourceUrl,
status: item.status,
imageUrl: item.imageUrl,
name: item.name,
price: item.price,
sourceUrl: item.sourceUrl,
}}
onSave={(data, onSuccess) => patchWishMutation(data, { onSuccess })}
isSavePending={isPatchWishPending}
Expand Down
17 changes: 5 additions & 12 deletions apps/web/src/app/archive/wish/[id]/_hooks/usePostWishRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,24 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

import { ITEM_STATUS } from '@/consts/item';
import { useSSEFallback } from '@/hooks/useSSEFallback';
import { isGlobalNetError } from '@/utils/apiError';

import { postWishRefresh } from '../_apis/postWishRefresh';
import type { GetWishResponseT } from '../_types/wish';

type WishItemT = GetWishResponseT['item'];

/** 갱신은 서버에서 비동기로 진행되고 완료는 SSE 가 알려주므로, 로딩·성패를 응답에 상품 정보가 실렸는지로 판단한다 */
export const usePostWishRefresh = (wishId: number, item: WishItemT) => {
export const usePostWishRefresh = (wishId: number, item: GetWishResponseT['item']) => {
const queryClient = useQueryClient();
const [isPostWishRefreshFailed, setIsPostWishRefreshFailed] = useState(false);
const [isRefreshRequested, setIsRefreshRequested] = useState(false);
const [itemBeforeRefresh, setItemBeforeRefresh] = useState<WishItemT | null>(null);

const isItemFailed = item.status === ITEM_STATUS.FAILED;
/** 갱신 중에는 상품 정보가 빈 응답으로 온다 */
const isItemLoading = !item.imageUrl && !isItemFailed;

useSSEFallback(['wish', wishId], item.refreshing);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { mutate: postWishRefreshMutation, isPending: isPostWishRefreshPending } = useMutation({
mutationFn: () => postWishRefresh(wishId),
onSuccess: async () => {
/** PROCESSING 을 받을 때까지 pending 을 유지해 로딩이 끊기지 않게 한다 */
await queryClient.invalidateQueries({ queryKey: ['wish', wishId] });
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
},
Expand All @@ -36,15 +32,12 @@ export const usePostWishRefresh = (wishId: number, item: WishItemT) => {

const refreshWish = () => {
setIsRefreshRequested(true);
setItemBeforeRefresh(item);
postWishRefreshMutation();
};

return {
/** 갱신 직전 정보로 화면을 유지한다 */
wishItem: item.imageUrl ? item : (itemBeforeRefresh ?? item),
refreshWish,
isPostWishRefreshPending: isPostWishRefreshPending || isItemLoading,
isPostWishRefreshPending: isPostWishRefreshPending || item.refreshing,
/** 원래 실패해 있던 위시와 구분해, 갱신으로 실패한 경우만 안내한다 */
isPostWishRefreshFailed: isPostWishRefreshFailed || (isRefreshRequested && isItemFailed),
closePostWishRefreshFailed: () => {
Expand Down
17 changes: 8 additions & 9 deletions apps/web/src/app/archive/wish/[id]/_types/wish.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
import type { ITEM_STATUS } from '@/consts/item';
import type { PatchItemRequestT, PriceHistorySourceT, PriceHistoryT } from '@/types/item';
import type { ItemExtractMethodT, PatchItemRequestT, PriceHistoryT } from '@/types/item';
import type { WishT } from '@/types/wish';

type ItemT = {
id: number;
source: PriceHistorySourceT | null;
/** 이미지로 등록한 항목은 원본 URL 이 없어 null */
sourceUrl: string | null;
sourcePlatform: string | null;
method: ItemExtractMethodT | null;
userInput: boolean;
refreshing: boolean;
} & (
| {
status:
| (typeof ITEM_STATUS)['PENDING']
| (typeof ITEM_STATUS)['PROCESSING']
| (typeof ITEM_STATUS)['FAILED'];
id: null;
status: (typeof ITEM_STATUS)['PARSING'] | (typeof ITEM_STATUS)['FAILED'];
name: null;
imageUrl: null;
price: null;
currency: null;
}
| {
id: number;
status: (typeof ITEM_STATUS)['INCOMPLETE'];
name: string | null;
imageUrl: string | null;
price: number | null;
currency: string | null;
}
| {
id: number;
status: (typeof ITEM_STATUS)['READY'];
name: string;
imageUrl: string;
Expand Down Expand Up @@ -55,6 +56,4 @@ export type GetWishResponseT = {
export type PatchWishResponseT = {
wish: WishT;
item: ItemT;
refreshNeeded: null;
reused: null;
};
8 changes: 2 additions & 6 deletions apps/web/src/app/archive/wish/[id]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,8 @@ async function WishEditLayout({ children, params }: WishEditLayoutProps) {
const wishData = await getWish(wishId);
queryClient.setQueryData(['wish', wishId], wishData);

/** 아직 PROCESSING 상태인 경우에는 접근 불가 */
if (
wishData.item.status === ITEM_STATUS.PROCESSING ||
wishData.item.status === ITEM_STATUS.PENDING
)
redirect(ROUTES.WISHLIST);
/** 아직 PARSING 상태인 경우에는 접근 불가 */
if (wishData.item.status === ITEM_STATUS.PARSING) redirect(ROUTES.WISHLIST);
} catch (error) {
if (!isAxiosError<ApiErrorResponseT>(error)) throw error;

Expand Down
7 changes: 1 addition & 6 deletions apps/web/src/app/archive/wish/_components/WishContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,7 @@ function WishContent() {

const { wishlistData } = useGetWishlist();
const selectableIds = wishlistData
.filter(
({ item }) =>
item.status !== ITEM_STATUS.FAILED &&
item.status !== ITEM_STATUS.PENDING &&
item.status !== ITEM_STATUS.PROCESSING
)
.filter(({ item }) => item.status !== ITEM_STATUS.FAILED && item.status !== ITEM_STATUS.PARSING)
.map(({ wish }) => wish.id);
const isAllSelected = selectableIds.length > 0 && selectableIds.every(id => selectedIds.has(id));

Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/archive/wish/_components/WishlistList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import { useEffect, useRef } from 'react';

import { ITEM_STATUS } from '@/consts/item';
import { useGetWishlist } from '@/hooks/useGetWishlist';
import { useSSEFallback } from '@/hooks/useSSEFallback';
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { hasParsingItems } from '@/utils/item';
import { SCROLL_NAMESPACE } from '@/utils/scrollRestoration';

import WishCardSkeleton from './WishCardSkeleton';
Expand All @@ -21,7 +21,9 @@ function WishlistList({ isDeleteMode, selectedIds, onToggleSelect }: WishlistLis
const sentinelRef = useRef<HTMLDivElement>(null);

const { wishlistData, fetchNextPage, hasNextPage, isFetchingNextPage } = useGetWishlist();
const hasPendingItem = hasParsingItems(wishlistData.map(({ item }) => item));
const hasPendingItem = wishlistData.some(
({ item }) => item.status === ITEM_STATUS.PARSING || item.refreshing
);

useSSEFallback(['wishlists'], hasPendingItem);
useScrollRestoration({ namespace: SCROLL_NAMESPACE.ARCHIVE_WISH });
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/app/archive/wish/_components/wish-grid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }:
/>
);

/** 파싱 중인 위시는 진입할 곳이 없다 */
if (item.status === ITEM_STATUS.PENDING || item.status === ITEM_STATUS.PROCESSING)
return <div key={wish.id}>{card}</div>;
/** 파싱 중인 위시는 위시 상세 페이지 진입 불가능 */
if (item.status === ITEM_STATUS.PARSING) return <div key={wish.id}>{card}</div>;

/** 삭제 모드는 READY 만 선택 가능 */
if (isDeleteMode && item.status === ITEM_STATUS.READY) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type TournamentMatchT = {
*/
export type PendingTournamentItemT = Partial<TournamentItemT> & {
tournamentItemId: number;
itemId: number;
itemId: number | null;
userId?: string;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { useQueryAction } from '@/hooks/useQueryAction';
import { useQueryParamOnce } from '@/hooks/useQueryParamOnce';
import { useSSEFallback } from '@/hooks/useSSEFallback';
import { hasSentInvite } from '@/utils/inviteSentSession';
import { hasParsingItems } from '@/utils/item';
import { parseIdParam } from '@/utils/parseIdParam';

import { useGetTournament } from '../../_common/_hooks/useGetTournament';
Expand Down Expand Up @@ -80,7 +79,7 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) {
const pending = 'pending' in tournamentData ? tournamentData.pending : null;

// 아이템 파싱 중이면 SSE 로 실시간 업데이트 (연결 실패 시 polling fallback).
const hasPendingItem = hasParsingItems(pending?.items ?? []);
const hasPendingItem = (pending?.items ?? []).some(item => item.status === ITEM_STATUS.PARSING);
useSSEFallback(['tournament', tournamentId], hasPendingItem);

const hasUnfinishedItem = (pending?.items ?? []).some(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const errorFallback = (status: ItemStatusT<'FAILED' | 'INCOMPLETE'> = 'FAILED')
);

function ProductImage({ src, alt, status, className }: ProductImageProps) {
const isProcessing = status === ITEM_STATUS.PENDING || status === ITEM_STATUS.PROCESSING;
const isParsing = status === ITEM_STATUS.PARSING;
const isError = status === ITEM_STATUS.FAILED || status === ITEM_STATUS.INCOMPLETE;

return (
Expand All @@ -44,9 +44,9 @@ function ProductImage({ src, alt, status, className }: ProductImageProps) {
className
)}
>
{isProcessing && loadingFallback}
{isParsing && loadingFallback}
{isError && errorFallback(status)}
{!isProcessing && !isError && src && (
{!isParsing && !isError && src && (
<BaseImage
src={src}
alt={alt}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ function ByWishContent({ tournamentId }: ByWishContentProps) {
const existingItemIds = new Set(pending?.items.map(i => i.itemId) ?? []);
const items = wishlistData.filter(
({ item }) =>
item.status !== ITEM_STATUS.FAILED &&
item.status !== ITEM_STATUS.PROCESSING &&
item.status !== ITEM_STATUS.INCOMPLETE &&
!existingItemIds.has(item.id)
item.status === ITEM_STATUS.READY && item.id !== null && !existingItemIds.has(item.id)
);

const isWishlistLoaded = !hasNextPage && !isFetchingNextPage;
Expand All @@ -64,7 +61,7 @@ function ByWishContent({ tournamentId }: ByWishContentProps) {
const handleNext = () => {
const itemIds = items
.filter(({ wish }) => selectedIds.includes(wish.id))
.map(({ item }) => item.id);
.flatMap(({ item }) => item.id ?? []);
postTournamentItemsByWishMutation(itemIds);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ import type { ITEM_STATUS } from '@/consts/item';

export type GetTournamentItemResponseT = {
tournamentItemId: number;
itemId: number;
itemId: number | null;
/** 이미지로 등록한 경우 undefined */
sourceUrl?: string;
currency?: string;
/** 개인 메모 - 위시에서 가져오기가 아니거나 비회원인 경우 undefined */
memo?: string;
} & (
| {
status:
| (typeof ITEM_STATUS)['PENDING']
| (typeof ITEM_STATUS)['PROCESSING']
| (typeof ITEM_STATUS)['FAILED'];
status: (typeof ITEM_STATUS)['PARSING'] | (typeof ITEM_STATUS)['FAILED'];
name?: undefined;
imageUrl?: undefined;
price?: undefined;
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/app/tournament/[id]/item/[itemId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,8 @@ async function TournamentItemLayout({ children, params }: TournamentItemLayoutPr
queryClient.ensureQueryData({ queryKey: ['me'], queryFn: getMe }),
]);

/** 아직 PROCESSING 상태인 경우에는 접근 불가 */
if (
tournamentItemData.status === ITEM_STATUS.PROCESSING ||
tournamentItemData.status === ITEM_STATUS.PENDING
)
/** 아직 PARSING 상태인 경우에는 접근 불가 */
if (tournamentItemData.status === ITEM_STATUS.PARSING)
redirect(ROUTES.TOURNAMENT_CREATE(tournamentId));

/** FAILED·INCOMPLETE 상태인 경우 주최자나 등록한 본인만 접근 가능 */
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/app/tournament/[id]/match/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { QUERY_ACTION } from '@/consts/queryAction';
import { ROUTES } from '@/consts/route';
import { MIN_TOURNAMENT_ITEM_COUNT, TOURNAMENT_STATUS } from '@/consts/tournament';
import { getApiErrorCode, getApiErrorStatus } from '@/utils/apiError';
import { hasParsingItems } from '@/utils/item';
import { getQueryClient } from '@/utils/queryClient';

import { getTournament } from '../_common/_apis/getTournament';
Expand Down Expand Up @@ -51,10 +50,9 @@ const isNotStartableError = (error: unknown) => {
const notStartablePath = (tournamentId: number) =>
`${ROUTES.TOURNAMENT_CREATE(tournamentId)}?${QUERY_ACTION.KEY}=${QUERY_ACTION.VALUE.TOURNAMENT_NOT_STARTABLE}`;

/** 대기실 시작 버튼(hasPendingItem · hasUnfinishedItem)과 동일한 기준 */
const isStartable = (items: PendingTournamentItemT[]) =>
items.length >= MIN_TOURNAMENT_ITEM_COUNT &&
!hasParsingItems(items) &&
!items.some(item => item.status === ITEM_STATUS.PARSING) &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
!items.some(item => item.status === ITEM_STATUS.FAILED || item.status === ITEM_STATUS.INCOMPLETE);

async function TournamentPage({ params }: TournamentPageProps) {
Expand Down
Loading
Loading