Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e9c4bd5
fix: scroll-to-top 아이콘 이미지 크기 클래스 보정
Seoje1405 Feb 16, 2026
afaee3f
fix: view-count-toast 아이콘 이미지 크기 클래스 보정
Seoje1405 Feb 16, 2026
7ef0deb
refactor: product-service export 정리
Seoje1405 Feb 16, 2026
4da8d3d
refactor: 사용하지 않는 product-service 제거
Seoje1405 Feb 16, 2026
1abbd81
refactor: 리뷰 도메인 타입 확장 및 응답 모델 정리
Seoje1405 Feb 16, 2026
a2f4947
feat: 리뷰 액션에 목록 상세 도움돼요 API 추가
Seoje1405 Feb 16, 2026
0a7a8c1
feat: 상품 상세에 리뷰 요약 데이터 연동
Seoje1405 Feb 16, 2026
5c23ca5
feat: 리뷰 리스트 실데이터 조회 및 정렬 페이징 적용
Seoje1405 Feb 16, 2026
b7b947f
feat: 리뷰 카드 렌더링 및 도움돼요 서버 연동
Seoje1405 Feb 16, 2026
40fd5a8
feat: 리뷰 요약 집계 유틸 및 모달 필터 통계 추가
Seoje1405 Feb 16, 2026
31c3e26
fix: 리뷰 섹션 탭 전환 및 빈상태 처리 보정
Seoje1405 Feb 16, 2026
8b664ae
refactor: 리뷰 모달 시트 접근성 타이틀 보완
Seoje1405 Feb 16, 2026
2e09d12
remove: 미사용 리뷰 상세 모달 컴포넌트 제거
Seoje1405 Feb 16, 2026
20e40ee
design: AI 소재 캐러셀 카드 너비 및 문장 흐름 조정
Seoje1405 Feb 16, 2026
d61af23
refactor: 리뷰 도메인 타입과 상수 소스 일원화
Seoje1405 Feb 16, 2026
80497c3
fix: 리뷰 액션 쿼리 타입과 파라미터 우선순위 보정
Seoje1405 Feb 16, 2026
adaff9a
fix: 리뷰 리스트 요청 경쟁 상태 완화 및 아이템 접근성 문구 정리
Seoje1405 Feb 16, 2026
482be85
fix: 리뷰 탭 컴포넌트 export 패턴 통일 및 기본 필터값 보정
Seoje1405 Feb 16, 2026
2b66ffa
fix: 리뷰 요약 모달 재조회 조건과 범위 안전성 보정
Seoje1405 Feb 16, 2026
a5aa282
refactor: 리뷰 집계 유틸 캐시 적용 및 빈메시지 분기 정리
Seoje1405 Feb 16, 2026
b3613d5
fix: 리뷰 모달 이벤트 정리 및 정렬 버튼 타입 명시
Seoje1405 Feb 16, 2026
61277b2
fix: 리뷰 요약 라벨 중복 렌더링 제거
Seoje1405 Feb 16, 2026
8c9bb53
fix: 리뷰 요약 모달 중복 조회 완화 및 섹션 렌더 정리
Seoje1405 Feb 16, 2026
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
171 changes: 162 additions & 9 deletions src/app/actions/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

import { redirect } from 'next/navigation';

import { api } from '@/lib/api-client';
import { ApiError, api } from '@/lib/api-client';
import { rethrowNextError } from '@/lib/server-action-utils';
import type {
PageResponse,
ProductReviewListItem,
ReviewDetail,
ReviewHelpfulData,
ReviewStatsData,
} from '@/types/domain/review';

interface InitReviewResponseData {
reviewId: number;
Expand Down Expand Up @@ -44,12 +52,80 @@ interface ActionResult<T = undefined> {
data?: T;
}

export interface ProductReviewsQuery {
reviewType?: 'INITIAL' | 'ONE_MONTH';
size?: string | string[];
color?: string | string[];
sort?: 'BEST' | 'RECENT' | 'RATING_HIGH' | 'RATING_LOW';
mySizeOnly?: boolean;
page?: number;
pageSize?: number;
}

const REVIEW_LIST_DEFAULT_PAGE_SIZE = 10;

function createEmptyReviewSummary(): ReviewStatsData {
return {
averageRating: 0,
initialReviewCount: 0,
oneMonthReviewCount: 0,
sizeSummary: {
category: '사이즈',
totalCount: 0,
topAnswer: null,
topAnswerCount: 0,
answerStats: [],
},
colorSummary: {
category: '색감',
totalCount: 0,
topAnswer: null,
topAnswerCount: 0,
answerStats: [],
},
materialSummary: {
category: '소재',
totalCount: 0,
topAnswer: null,
topAnswerCount: 0,
answerStats: [],
},
};
}

function createEmptyReviewPage(
page = 0,
pageSize = 10,
): PageResponse<ProductReviewListItem> {
return {
totalPages: 0,
totalElements: 0,
pageable: {
paged: true,
pageNumber: page,
pageSize,
offset: page * pageSize,
sort: [],
unpaged: false,
},
first: true,
last: true,
size: pageSize,
content: [],
number: page,
sort: [],
numberOfElements: 0,
empty: true,
};
}

export async function initPendingReviewAction(formData: FormData) {
const rawOrderItemId = formData.get('orderItemId');
const rawProductId = formData.get('productId');
const orderItemId =
typeof rawOrderItemId === 'string' ? Number(rawOrderItemId) : NaN;
const productId = typeof rawProductId === 'string' ? Number(rawProductId) : NaN;
const productId =
typeof rawProductId === 'string' ? Number(rawProductId) : NaN;

if (!Number.isFinite(orderItemId)) {
return;
Expand Down Expand Up @@ -123,21 +199,98 @@ export async function submitReviewAction(
payload: ReviewSubmitRequest,
): Promise<ActionResult> {
try {
console.log('[review-submit] request', {
endpoint: `/reviews/${reviewId}/submit`,
reviewId,
payload,
});

await api.post<Record<string, never>, ReviewSubmitRequest>(
`/reviews/${reviewId}/submit`,
payload,
);

console.log('[review-submit] success', { reviewId });
return { success: true };
} catch (error) {
console.error('리뷰 제출 실패:', error);
return { success: false, message: '리뷰 제출에 실패했습니다.' };
}
}

/** 리뷰 도움돼요 토글 */
export async function toggleReviewHelpfulAction(
reviewId: number,
): Promise<ActionResult<ReviewHelpfulData>> {
try {
const data = await api.post<ReviewHelpfulData, Record<string, never>>(
`/reviews/${reviewId}/helpful`,
{},
);
return { success: true, data };
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return { success: false, message: '리뷰를 찾을 수 없습니다.' };
}
rethrowNextError(error);
console.error('리뷰 도움돼요 토글 실패:', { error, reviewId });
return { success: false, message: '도움돼요 처리에 실패했습니다.' };
}
}

/** 리뷰 상세 조회 */
export async function getReviewDetailAction(
reviewId: number,
): Promise<ReviewDetail | null> {
try {
const detail = await api.get<ReviewDetail>(`/reviews/${reviewId}/details`);
return detail;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
console.warn('리뷰 상세 조회: 리뷰를 찾을 수 없습니다.', { reviewId });
return null;
}
rethrowNextError(error);
console.error('리뷰 상세 조회 실패:', error);
throw new Error(
error instanceof Error ? error.message : '리뷰 상세 조회에 실패했습니다.',
);
}
}

/** 상품별 리뷰 목록 조회 */
export async function getProductReviewsAction(
productId: number,
query: ProductReviewsQuery = {},
): Promise<PageResponse<ProductReviewListItem>> {
const page = query.page ?? 0;
const pageSize = query.pageSize ?? REVIEW_LIST_DEFAULT_PAGE_SIZE;
const params: Record<
string,
string | number | boolean | string[] | undefined
> = { ...query };
params.page = page;
params.pageSize = pageSize;
params.sort = query.sort ?? 'BEST';

try {
return await api.get<PageResponse<ProductReviewListItem>>(
`/products/${productId}/reviews`,
{
params,
},
);
} catch (error) {
rethrowNextError(error);
console.error('상품 리뷰 목록 조회 실패:', { error, productId, params });
return createEmptyReviewPage(page, pageSize);
}
}

/** 리뷰 통계 요약 조회 */
export async function getProductReviewsSummaryAction(
productId: number,
): Promise<ReviewStatsData> {
try {
return await api.get<ReviewStatsData>(
`/products/${productId}/reviews/summary`,
);
} catch (error) {
rethrowNextError(error);
console.error('리뷰 통계 요약 조회 실패:', { error, productId });
return createEmptyReviewSummary();
}
}
37 changes: 28 additions & 9 deletions src/app/product/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation';
import { getProductDetail, getSimilarProducts } from '@/app/actions/product';
import { getMyBodyInfoAction } from '@/app/actions/body-info';
import { getProductReviewsSummaryAction } from '@/app/actions/review';
import { fetchSizeAnalysis } from '@/mocks/size';
import ProductDetailView from '@/components/product/product-detail-view';
import { getMyWishlist } from '@/app/actions/wishlist';
Expand All @@ -26,36 +27,54 @@ export default async function ProductPage({ params, searchParams }: PageProps) {
notFound();
}

const [bodyInfoResult, wishlist, similarProducts] = await Promise.all([
getMyBodyInfoAction(),
getMyWishlist(),
getSimilarProducts(productId),
]);
const [bodyInfoResult, wishlist, similarProducts, reviewSummaryResult] =
await Promise.allSettled([
getMyBodyInfoAction(),
getMyWishlist(),
getSimilarProducts(productId),
getProductReviewsSummaryAction(productId),
]);

const resolvedBodyInfo =
bodyInfoResult.status === 'fulfilled'
? bodyInfoResult.value
: { success: false as const, data: null };
const resolvedWishlist =
wishlist.status === 'fulfilled' ? wishlist.value : [];
const resolvedSimilarProducts =
similarProducts.status === 'fulfilled' ? similarProducts.value : [];
const reviewSummary =
reviewSummaryResult.status === 'fulfilled'
? reviewSummaryResult.value
: undefined;

const userInfo =
bodyInfoResult.success && bodyInfoResult.data?.hasBodyInfo
? bodyInfoResult.data
resolvedBodyInfo.success && resolvedBodyInfo.data?.hasBodyInfo
? resolvedBodyInfo.data
: null;

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

// 2. 찜 목록에서 현재 상품이 있는지 확인
const wishlistItem = wishlist.find((item) => item.productId === productId);
const wishlistItem = resolvedWishlist.find(
(item) => item.productId === productId,
);
const isLiked = !!wishlistItem;
const wishlistId = wishlistItem?.wishlistId;

// 3. 클라이언트 뷰에 데이터 전달하며 렌더링
return (
<ProductDetailView
product={product}
similarProducts={similarProducts}
similarProducts={resolvedSimilarProducts}
userInfo={userInfo}
analysisData={analysisData}
backHref={backHref}
isLiked={isLiked}
wishlistId={wishlistId}
productReviewSummary={reviewSummary}
/>
);
}
9 changes: 0 additions & 9 deletions src/components/product/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,6 @@ export {
useProductInteraction,
} from './product-interaction-context';

// ----------------------------------------------------------------------
// 6. 데이터 비즈니스 로직
// ----------------------------------------------------------------------
export {
getProductsByCategoryId,
getProductById,
getCategoryTitle,
} from './product-service';

// ----------------------------------------------------------------------
// 7. 사이즈 관련 컴포넌트
// ----------------------------------------------------------------------
Expand Down
Loading