Skip to content

Commit fb4b21a

Browse files
authored
Merge pull request #67 from Seoje1405/refactor/review
[FEAT]: 상품 상세 리뷰 탭 실데이터 연동 및 요약 통계 개선
2 parents 6035ec8 + 8c9bb53 commit fb4b21a

24 files changed

Lines changed: 1641 additions & 606 deletions

src/app/actions/review.ts

Lines changed: 162 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
import { redirect } from 'next/navigation';
44

5-
import { api } from '@/lib/api-client';
5+
import { ApiError, api } from '@/lib/api-client';
6+
import { rethrowNextError } from '@/lib/server-action-utils';
7+
import type {
8+
PageResponse,
9+
ProductReviewListItem,
10+
ReviewDetail,
11+
ReviewHelpfulData,
12+
ReviewStatsData,
13+
} from '@/types/domain/review';
614

715
interface InitReviewResponseData {
816
reviewId: number;
@@ -44,12 +52,80 @@ interface ActionResult<T = undefined> {
4452
data?: T;
4553
}
4654

55+
export interface ProductReviewsQuery {
56+
reviewType?: 'INITIAL' | 'ONE_MONTH';
57+
size?: string | string[];
58+
color?: string | string[];
59+
sort?: 'BEST' | 'RECENT' | 'RATING_HIGH' | 'RATING_LOW';
60+
mySizeOnly?: boolean;
61+
page?: number;
62+
pageSize?: number;
63+
}
64+
65+
const REVIEW_LIST_DEFAULT_PAGE_SIZE = 10;
66+
67+
function createEmptyReviewSummary(): ReviewStatsData {
68+
return {
69+
averageRating: 0,
70+
initialReviewCount: 0,
71+
oneMonthReviewCount: 0,
72+
sizeSummary: {
73+
category: '사이즈',
74+
totalCount: 0,
75+
topAnswer: null,
76+
topAnswerCount: 0,
77+
answerStats: [],
78+
},
79+
colorSummary: {
80+
category: '색감',
81+
totalCount: 0,
82+
topAnswer: null,
83+
topAnswerCount: 0,
84+
answerStats: [],
85+
},
86+
materialSummary: {
87+
category: '소재',
88+
totalCount: 0,
89+
topAnswer: null,
90+
topAnswerCount: 0,
91+
answerStats: [],
92+
},
93+
};
94+
}
95+
96+
function createEmptyReviewPage(
97+
page = 0,
98+
pageSize = 10,
99+
): PageResponse<ProductReviewListItem> {
100+
return {
101+
totalPages: 0,
102+
totalElements: 0,
103+
pageable: {
104+
paged: true,
105+
pageNumber: page,
106+
pageSize,
107+
offset: page * pageSize,
108+
sort: [],
109+
unpaged: false,
110+
},
111+
first: true,
112+
last: true,
113+
size: pageSize,
114+
content: [],
115+
number: page,
116+
sort: [],
117+
numberOfElements: 0,
118+
empty: true,
119+
};
120+
}
121+
47122
export async function initPendingReviewAction(formData: FormData) {
48123
const rawOrderItemId = formData.get('orderItemId');
49124
const rawProductId = formData.get('productId');
50125
const orderItemId =
51126
typeof rawOrderItemId === 'string' ? Number(rawOrderItemId) : NaN;
52-
const productId = typeof rawProductId === 'string' ? Number(rawProductId) : NaN;
127+
const productId =
128+
typeof rawProductId === 'string' ? Number(rawProductId) : NaN;
53129

54130
if (!Number.isFinite(orderItemId)) {
55131
return;
@@ -123,21 +199,98 @@ export async function submitReviewAction(
123199
payload: ReviewSubmitRequest,
124200
): Promise<ActionResult> {
125201
try {
126-
console.log('[review-submit] request', {
127-
endpoint: `/reviews/${reviewId}/submit`,
128-
reviewId,
129-
payload,
130-
});
131-
132202
await api.post<Record<string, never>, ReviewSubmitRequest>(
133203
`/reviews/${reviewId}/submit`,
134204
payload,
135205
);
136206

137-
console.log('[review-submit] success', { reviewId });
138207
return { success: true };
139208
} catch (error) {
140209
console.error('리뷰 제출 실패:', error);
141210
return { success: false, message: '리뷰 제출에 실패했습니다.' };
142211
}
143212
}
213+
214+
/** 리뷰 도움돼요 토글 */
215+
export async function toggleReviewHelpfulAction(
216+
reviewId: number,
217+
): Promise<ActionResult<ReviewHelpfulData>> {
218+
try {
219+
const data = await api.post<ReviewHelpfulData, Record<string, never>>(
220+
`/reviews/${reviewId}/helpful`,
221+
{},
222+
);
223+
return { success: true, data };
224+
} catch (error) {
225+
if (error instanceof ApiError && error.status === 404) {
226+
return { success: false, message: '리뷰를 찾을 수 없습니다.' };
227+
}
228+
rethrowNextError(error);
229+
console.error('리뷰 도움돼요 토글 실패:', { error, reviewId });
230+
return { success: false, message: '도움돼요 처리에 실패했습니다.' };
231+
}
232+
}
233+
234+
/** 리뷰 상세 조회 */
235+
export async function getReviewDetailAction(
236+
reviewId: number,
237+
): Promise<ReviewDetail | null> {
238+
try {
239+
const detail = await api.get<ReviewDetail>(`/reviews/${reviewId}/details`);
240+
return detail;
241+
} catch (error) {
242+
if (error instanceof ApiError && error.status === 404) {
243+
console.warn('리뷰 상세 조회: 리뷰를 찾을 수 없습니다.', { reviewId });
244+
return null;
245+
}
246+
rethrowNextError(error);
247+
console.error('리뷰 상세 조회 실패:', error);
248+
throw new Error(
249+
error instanceof Error ? error.message : '리뷰 상세 조회에 실패했습니다.',
250+
);
251+
}
252+
}
253+
254+
/** 상품별 리뷰 목록 조회 */
255+
export async function getProductReviewsAction(
256+
productId: number,
257+
query: ProductReviewsQuery = {},
258+
): Promise<PageResponse<ProductReviewListItem>> {
259+
const page = query.page ?? 0;
260+
const pageSize = query.pageSize ?? REVIEW_LIST_DEFAULT_PAGE_SIZE;
261+
const params: Record<
262+
string,
263+
string | number | boolean | string[] | undefined
264+
> = { ...query };
265+
params.page = page;
266+
params.pageSize = pageSize;
267+
params.sort = query.sort ?? 'BEST';
268+
269+
try {
270+
return await api.get<PageResponse<ProductReviewListItem>>(
271+
`/products/${productId}/reviews`,
272+
{
273+
params,
274+
},
275+
);
276+
} catch (error) {
277+
rethrowNextError(error);
278+
console.error('상품 리뷰 목록 조회 실패:', { error, productId, params });
279+
return createEmptyReviewPage(page, pageSize);
280+
}
281+
}
282+
283+
/** 리뷰 통계 요약 조회 */
284+
export async function getProductReviewsSummaryAction(
285+
productId: number,
286+
): Promise<ReviewStatsData> {
287+
try {
288+
return await api.get<ReviewStatsData>(
289+
`/products/${productId}/reviews/summary`,
290+
);
291+
} catch (error) {
292+
rethrowNextError(error);
293+
console.error('리뷰 통계 요약 조회 실패:', { error, productId });
294+
return createEmptyReviewSummary();
295+
}
296+
}

src/app/product/[id]/page.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { notFound } from 'next/navigation';
22
import { getProductDetail, getSimilarProducts } from '@/app/actions/product';
33
import { getMyBodyInfoAction } from '@/app/actions/body-info';
4+
import { getProductReviewsSummaryAction } from '@/app/actions/review';
45
import { fetchSizeAnalysis } from '@/mocks/size';
56
import ProductDetailView from '@/components/product/product-detail-view';
67
import { getMyWishlist } from '@/app/actions/wishlist';
@@ -26,36 +27,54 @@ export default async function ProductPage({ params, searchParams }: PageProps) {
2627
notFound();
2728
}
2829

29-
const [bodyInfoResult, wishlist, similarProducts] = await Promise.all([
30-
getMyBodyInfoAction(),
31-
getMyWishlist(),
32-
getSimilarProducts(productId),
33-
]);
30+
const [bodyInfoResult, wishlist, similarProducts, reviewSummaryResult] =
31+
await Promise.allSettled([
32+
getMyBodyInfoAction(),
33+
getMyWishlist(),
34+
getSimilarProducts(productId),
35+
getProductReviewsSummaryAction(productId),
36+
]);
37+
38+
const resolvedBodyInfo =
39+
bodyInfoResult.status === 'fulfilled'
40+
? bodyInfoResult.value
41+
: { success: false as const, data: null };
42+
const resolvedWishlist =
43+
wishlist.status === 'fulfilled' ? wishlist.value : [];
44+
const resolvedSimilarProducts =
45+
similarProducts.status === 'fulfilled' ? similarProducts.value : [];
46+
const reviewSummary =
47+
reviewSummaryResult.status === 'fulfilled'
48+
? reviewSummaryResult.value
49+
: undefined;
3450

3551
const userInfo =
36-
bodyInfoResult.success && bodyInfoResult.data?.hasBodyInfo
37-
? bodyInfoResult.data
52+
resolvedBodyInfo.success && resolvedBodyInfo.data?.hasBodyInfo
53+
? resolvedBodyInfo.data
3854
: null;
3955

4056
const analysisData = userInfo
4157
? await fetchSizeAnalysis(id, userInfo.height, userInfo.weight)
4258
: null;
4359

4460
// 2. 찜 목록에서 현재 상품이 있는지 확인
45-
const wishlistItem = wishlist.find((item) => item.productId === productId);
61+
const wishlistItem = resolvedWishlist.find(
62+
(item) => item.productId === productId,
63+
);
4664
const isLiked = !!wishlistItem;
4765
const wishlistId = wishlistItem?.wishlistId;
4866

4967
// 3. 클라이언트 뷰에 데이터 전달하며 렌더링
5068
return (
5169
<ProductDetailView
5270
product={product}
53-
similarProducts={similarProducts}
71+
similarProducts={resolvedSimilarProducts}
5472
userInfo={userInfo}
5573
analysisData={analysisData}
5674
backHref={backHref}
5775
isLiked={isLiked}
5876
wishlistId={wishlistId}
77+
productReviewSummary={reviewSummary}
5978
/>
6079
);
6180
}

src/components/product/index.tsx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,6 @@ export {
3535
useProductInteraction,
3636
} from './product-interaction-context';
3737

38-
// ----------------------------------------------------------------------
39-
// 6. 데이터 비즈니스 로직
40-
// ----------------------------------------------------------------------
41-
export {
42-
getProductsByCategoryId,
43-
getProductById,
44-
getCategoryTitle,
45-
} from './product-service';
46-
4738
// ----------------------------------------------------------------------
4839
// 7. 사이즈 관련 컴포넌트
4940
// ----------------------------------------------------------------------

0 commit comments

Comments
 (0)