+
diff --git a/src/components/mover/MoversErrorPanel.tsx b/src/components/mover/MoversErrorPanel.tsx
new file mode 100644
index 00000000..3c583333
--- /dev/null
+++ b/src/components/mover/MoversErrorPanel.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import Button from "@/components/common/Button/Button";
+import { Text } from "@/components/common/Text";
+
+export interface MoversErrorPanelProps {
+ title: string;
+ description: string;
+ actionLabel: string;
+ isRetrying: boolean;
+ onRetry: () => void;
+}
+
+/** 기사님 찾기 / 상세 공통 네트워크·조회 실패 패널 */
+export default function MoversErrorPanel({
+ title,
+ description,
+ actionLabel,
+ isRetrying,
+ onRetry,
+}: MoversErrorPanelProps) {
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ );
+}
diff --git a/src/components/mover/MoversList.tsx b/src/components/mover/MoversList.tsx
index 7bc5fdb0..acca5e7e 100644
--- a/src/components/mover/MoversList.tsx
+++ b/src/components/mover/MoversList.tsx
@@ -2,12 +2,11 @@
import { useEffect, useRef, useState } from "react";
-import Button from "@/components/common/Button/Button";
import EmptyState from "@/components/common/EmptyState/EmptyState";
import Toast from "@/components/common/Toast/Toast";
-import { Text } from "@/components/common/Text";
import MoverCard from "@/components/mover/MoverCard";
import { MoverCardSkeletonList } from "@/components/mover/MoverCardSkeleton";
+import MoversErrorPanel from "@/components/mover/MoversErrorPanel";
import { useMovers } from "@/hooks/useMovers";
import { mapMoverListItemToMover } from "@/lib/utils/mapMover";
import type { MoversSearchParamsState } from "@/lib/utils/moversSearchParams";
@@ -16,14 +15,6 @@ interface MoversListProps {
filters: MoversSearchParamsState;
}
-interface MoversListErrorPanelProps {
- title: string;
- description: string;
- actionLabel: string;
- isRetrying: boolean;
- onRetry: () => void;
-}
-
const MOVERS_EMPTY_DESCRIPTION = (
<>
검색 결과가 없어요.
@@ -37,37 +28,6 @@ const MOVERS_LIST_SKELETON_COUNT = 3;
/** 다음 페이지 fetch 중 하단 스켈레톤 카드 수 */
const MOVERS_NEXT_PAGE_SKELETON_COUNT = 2;
-function MoversListErrorPanel({
- title,
- description,
- actionLabel,
- isRetrying,
- onRetry,
-}: MoversListErrorPanelProps) {
- return (
-
-
-
- {title}
-
-
- {description}
-
-
-
-
- );
-}
-
export function MoversList({ filters }: MoversListProps) {
const query = useMovers(filters);
const sentinelRef = useRef
(null);
@@ -109,7 +69,7 @@ export function MoversList({ filters }: MoversListProps) {
if (query.isError) {
return (
-
+
+ 기사님을 찾을 수 없습니다.
+
+ 주소가 잘못되었거나, 존재하지 않는 기사님입니다.
+ >
+ }
+ buttonLabel="기사님 찾기로 돌아가기"
+ href="/movers"
+ />
+
+ );
+}
diff --git a/src/components/mover/detail/MoverDetailPageSkeleton.tsx b/src/components/mover/detail/MoverDetailPageSkeleton.tsx
new file mode 100644
index 00000000..9e9a40ae
--- /dev/null
+++ b/src/components/mover/detail/MoverDetailPageSkeleton.tsx
@@ -0,0 +1,129 @@
+import { Skeleton } from "@/components/common/Skeleton/Skeleton";
+import { MOVER_REVIEW_PAGE_LIMIT } from "@/lib/api/movers";
+import { cn } from "@/lib/utils/cn";
+
+interface MoverDetailReviewsSkeletonProps {
+ count?: number;
+ className?: string;
+}
+
+/** 리뷰 목록 로딩 스켈레톤 */
+export function MoverDetailReviewsSkeleton({
+ count = MOVER_REVIEW_PAGE_LIMIT,
+ className,
+}: MoverDetailReviewsSkeletonProps) {
+ return (
+
+ {Array.from({ length: count }, (_, index) => (
+ -
+
+
+ ))}
+
+ );
+}
+
+/** 기사 상세 페이지 전체 로딩 스켈레톤 */
+export default function MoverDetailPageSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/mover/detail/MoverDetailReviews.tsx b/src/components/mover/detail/MoverDetailReviews.tsx
index 803d860a..3647b0a4 100644
--- a/src/components/mover/detail/MoverDetailReviews.tsx
+++ b/src/components/mover/detail/MoverDetailReviews.tsx
@@ -1,28 +1,67 @@
"use client";
+import { useState } from "react";
+
import Pagination from "@/components/common/Pagination/Pagination";
import { Text } from "@/components/common/Text";
-import { MOVER_DETAIL_REVIEW_PAGE_LIMIT } from "@/components/mover/detail/moverDetailMock";
+import { MoverDetailReviewsSkeleton } from "@/components/mover/detail/MoverDetailPageSkeleton";
import ReviewStarRating from "@/components/review/ReviewStarRating";
+import { useMoverReviews } from "@/hooks/useMoverReviews";
+import { MOVER_REVIEW_PAGE_LIMIT } from "@/lib/api/movers";
+import { getApiErrorMessage } from "@/lib/api/getApiErrorMessage";
import { cn } from "@/lib/utils/cn";
-import { formatRating } from "@/lib/utils/estimateFormat";
+import { formatKoreanDateLong, formatRating } from "@/lib/utils/estimateFormat";
import type { MoverDetail, MoverDetailReview } from "@/types/moverDetail";
+import type { MoverReviewItem } from "@/types/review";
interface MoverDetailReviewsProps {
- detail: MoverDetail;
- currentPage: number;
- onPageChange: (page: number) => void;
+ moverId: string;
+ rating: number;
+ reviewCount: number;
+ ratingDistribution: MoverDetail["ratingDistribution"];
+}
+
+function mapMoverReviewItemToDetailReview(item: MoverReviewItem): MoverDetailReview {
+ return {
+ id: String(item.id),
+ authorMasked: item.customer.displayName,
+ createdAt: formatKoreanDateLong(item.createdAt),
+ rating: item.rating,
+ content: item.content,
+ };
}
export default function MoverDetailReviews({
- detail,
- currentPage,
- onPageChange,
+ moverId,
+ rating,
+ reviewCount,
+ ratingDistribution,
}: MoverDetailReviewsProps) {
- const isEmpty = detail.reviewCount === 0 || detail.reviews.length === 0;
- const maxCount = Math.max(...detail.ratingDistribution.map((item) => item.count), 1);
- const pageStart = (currentPage - 1) * MOVER_DETAIL_REVIEW_PAGE_LIMIT;
- const pagedReviews = detail.reviews.slice(pageStart, pageStart + MOVER_DETAIL_REVIEW_PAGE_LIMIT);
+ const [currentPage, setCurrentPage] = useState(1);
+ const { data, isLoading, isError, error, isFetching, refetch } = useMoverReviews(moverId, {
+ page: currentPage,
+ });
+
+ const reviews = data?.reviews.map(mapMoverReviewItemToDetailReview) ?? [];
+ const pageCount = Math.max(1, data?.pagination.totalPages ?? 0);
+ const hasReviews = reviews.length > 0;
+
+ // 초기 조회 시에만 스켈레톤 노출 (페이지 전환 중에는 keepPreviousData로 이전 페이지 데이터가 보임)
+ const isInitialLoading = isLoading && data === undefined;
+ const shouldShowError = isError && !hasReviews;
+ const shouldShowReviews = hasReviews;
+ const shouldShowPagination = pageCount > 1 && hasReviews;
+
+ // TODO: 시드 데이터 오류로 인한 임시 조치, 시드 데이터 수정 후 삭제 예정
+ const isEmpty =
+ reviewCount === 0 ||
+ (!isLoading && !isError && data !== undefined && data.pagination.totalCount === 0);
+ const hasDistribution = ratingDistribution.some((item) => item.count > 0);
+
+ const maxCount = Math.max(...ratingDistribution.map((item) => item.count), 1);
+ // 동점이면 더 높은 점수(5→1 순) 하나만 강조
+ const topScore =
+ ratingDistribution.find((item) => item.count === maxCount && item.count > 0)?.score ?? null;
return (
@@ -49,71 +88,102 @@ export default function MoverDetailReviews({
- {formatRating(detail.rating)}
+ {formatRating(rating)}
-
+
- {detail.reviewCount}개의 리뷰
+ {reviewCount}개의 리뷰
-
- {detail.ratingDistribution.map((item) => (
- -
- 0 ? "md-bold" : "md-medium"}
- className="text-text-tertiary w-36 shrink-0"
- >
- {item.score}점
-
-
- 0 ? "md-bold" : "md-medium"}
- className="text-rating-count w-36 shrink-0"
- >
- {item.count}
-
-
- ))}
-
+ {hasDistribution ? (
+
+ {ratingDistribution.map((item) => {
+ const isTop = item.score === topScore;
+
+ return (
+ -
+
+ {item.score}점
+
+
+
+ {item.count}
+
+
+ );
+ })}
+
+ ) : null}
-
- {pagedReviews.map((review, index) => (
-
+ ) : null}
+
+ {shouldShowError ? (
+
+
+ {getApiErrorMessage(error, "리뷰를 불러오지 못했습니다.")}
+
+
+
+ ) : null}
+
+ {shouldShowReviews ? (
+
+ {reviews.map((review, index) => (
+ -
+
+
+ ))}
+
+ ) : null}
- {detail.reviewPageCount > 1 ? (
+ {shouldShowPagination ? (
) : null}
diff --git a/src/components/mover/detail/MoverDetailView.tsx b/src/components/mover/detail/MoverDetailView.tsx
index 5ba9fe37..4b060bec 100644
--- a/src/components/mover/detail/MoverDetailView.tsx
+++ b/src/components/mover/detail/MoverDetailView.tsx
@@ -1,47 +1,67 @@
"use client";
-import { useMemo, useState } from "react";
+import { useState } from "react";
import Toast from "@/components/common/Toast/Toast";
import EstimateDetailHero from "@/components/estimate/detail/EstimateDetailHero";
import EstimateDetailShare from "@/components/estimate/detail/EstimateDetailShare";
import MoverDetailActions from "@/components/mover/detail/MoverDetailActions";
+import MoverDetailNotFoundStatus from "@/components/mover/detail/MoverDetailNotFoundStatus";
+import MoverDetailPageSkeleton from "@/components/mover/detail/MoverDetailPageSkeleton";
import MoverDetailProfile from "@/components/mover/detail/MoverDetailProfile";
import MoverDetailReviews from "@/components/mover/detail/MoverDetailReviews";
import MoverDetailServices from "@/components/mover/detail/MoverDetailServices";
-import { getMockMoverDetail } from "@/components/mover/detail/moverDetailMock";
-import type { MoverDetail } from "@/types/moverDetail";
+import MoversErrorPanel from "@/components/mover/MoversErrorPanel";
+import { useFavoriteMover } from "@/hooks/useFavoriteMover";
+import { useMoverDetail } from "@/hooks/useMoverDetail";
+import { ApiError } from "@/types/api";
interface MoverDetailViewProps {
moverId: string;
}
+function isMoverNotFoundError(error: unknown): boolean {
+ return error instanceof ApiError && (error.status === 404 || error.code === "MOVER_NOT_FOUND");
+}
+
export default function MoverDetailView({ moverId }: MoverDetailViewProps) {
- const initialDetail = useMemo(() => getMockMoverDetail(moverId), [moverId]);
- const [detail, setDetail] = useState(initialDetail);
- const [reviewPage, setReviewPage] = useState(1);
const [toastMessage, setToastMessage] = useState(null);
+ const { data: detail, isLoading, error, isFetching, refetch } = useMoverDetail(moverId);
+ const favoriteMutation = useFavoriteMover({ onError: setToastMessage });
+
+ if (isLoading) {
+ return ;
+ }
+
if (!detail) {
+ if (isMoverNotFoundError(error)) {
+ return ;
+ }
+
return (
-
-
기사님 정보를 찾을 수 없습니다.
+
+ {
+ void refetch();
+ }}
+ />
);
}
const toggleFavorite = () => {
- setDetail((prev) => {
- if (!prev) {
- return prev;
- }
-
- const nextIsFavorite = !prev.isFavorite;
- return {
- ...prev,
- isFavorite: nextIsFavorite,
- favoriteCount: Math.max(0, prev.favoriteCount + (nextIsFavorite ? 1 : -1)),
- };
+ if (favoriteMutation.isPending) {
+ return;
+ }
+
+ favoriteMutation.mutate({
+ moverId: detail.id,
+ nextIsFavorite: !detail.isFavorite,
});
};
@@ -71,9 +91,10 @@ export default function MoverDetailView({ moverId }: MoverDetailViewProps) {
diff --git a/src/components/mover/detail/moverDetailMock.ts b/src/components/mover/detail/moverDetailMock.ts
deleted file mode 100644
index 0943e0a3..00000000
--- a/src/components/mover/detail/moverDetailMock.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import type { MoverDetail, MoverDetailReview } from "@/types/moverDetail";
-
-const DEFAULT_PROFILE = "/images/profile-character.png";
-
-/** 리뷰 목록 페이지당 개수 (페이지네이션 UI 확인용) */
-export const MOVER_DETAIL_REVIEW_PAGE_LIMIT = 5;
-
-const MOCK_REVIEW_TOTAL = 45;
-
-const MOCK_REVIEW_CONTENTS = [
- "듣던대로 정말 친절하시고 물건도 잘 옮겨주셨어요!\n나중에 또 짐 옮길 일 생기면 김코드 기사님께 부탁드릴 예정입니다!!",
- "비 오는데도 꼼꼼히 잘 해주셔서 감사드립니다 :)",
- "시간 약속을 잘 지켜주셔서 좋았어요.",
- "포장이 꼼꼼해서 파손 없이 이사 완료했습니다.",
- "응대가 빠르고 설명이 친절했어요.",
-] as const;
-
-const MOCK_AUTHORS = ["kim****", "lee****", "park****", "choi****", "jung****"] as const;
-
-function buildMockReviews(): MoverDetailReview[] {
- return Array.from({ length: MOCK_REVIEW_TOTAL }, (_, index) => {
- const day = String((index % 28) + 1).padStart(2, "0");
-
- return {
- id: `review-${index + 1}`,
- authorMasked: MOCK_AUTHORS[index % MOCK_AUTHORS.length]!,
- createdAt: `2024-07-${day}`,
- rating: 5 - (index % 3 === 0 ? 1 : 0),
- content: `[#${index + 1}] ${MOCK_REVIEW_CONTENTS[index % MOCK_REVIEW_CONTENTS.length]!}`,
- };
- });
-}
-
-const MOCK_REVIEWS = buildMockReviews();
-
-/** NOTE: API 연동 전 기사님 상세 MOCK */
-export const MOCK_MOVER_DETAILS: Record = {
- "1": {
- id: "1",
- name: "김코드",
- serviceTypes: ["SMALL", "HOME"],
- serviceAreas: [1, 9],
- title: "고객님의 물품을 안전하게 운송해 드립니다.",
- description:
- "안녕하세요. 이사업계 경력 7년으로 안전한 이사를 도와드리는 김코드입니다.\n고객님의 물품을 소중하고 안전하게 운송하여 드립니다. 소형이사 및 가정이사 서비스를 제공하며 서비스 가능 지역은 서울과 경기권입니다.",
- rating: 5.0,
- reviewCount: 178,
- careerYears: 7,
- confirmedCount: 334,
- favoriteCount: 136,
- isFavorite: false,
- profileImageSrc: DEFAULT_PROFILE,
- ratingDistribution: [
- { score: 5, count: 170 },
- { score: 4, count: 8 },
- { score: 3, count: 0 },
- { score: 2, count: 0 },
- { score: 1, count: 0 },
- ],
- reviews: MOCK_REVIEWS,
- reviewPageCount: Math.ceil(MOCK_REVIEW_TOTAL / MOVER_DETAIL_REVIEW_PAGE_LIMIT),
- },
- /** 리뷰 빈 상태 확인용 — `/movers/empty` */
- empty: {
- id: "empty",
- name: "김코드",
- serviceTypes: ["SMALL", "HOME"],
- serviceAreas: [1, 9],
- title: "고객님의 물품을 안전하게 운송해 드립니다.",
- description:
- "안녕하세요. 이사업계 경력 7년으로 안전한 이사를 도와드리는 김코드입니다. 고객님의 물품을 소중하고 안전하게 운송하여 드립니다. 소형이사 및 가정이사 서비스를 제공하며 서비스 가능 지역은 서울과 경기권입니다.",
- rating: 0,
- reviewCount: 0,
- careerYears: 7,
- confirmedCount: 334,
- favoriteCount: 136,
- isFavorite: false,
- profileImageSrc: DEFAULT_PROFILE,
- ratingDistribution: [
- { score: 5, count: 0 },
- { score: 4, count: 0 },
- { score: 3, count: 0 },
- { score: 2, count: 0 },
- { score: 1, count: 0 },
- ],
- reviews: [],
- reviewPageCount: 0,
- },
-};
-
-export function getMockMoverDetail(moverId: string): MoverDetail | null {
- if (MOCK_MOVER_DETAILS[moverId]) {
- return MOCK_MOVER_DETAILS[moverId];
- }
-
- // 목록 MOCK id와 맞추기 위해 기본 상세를 id만 바꿔 반환
- const fallback = MOCK_MOVER_DETAILS["1"];
- if (!fallback) {
- return null;
- }
-
- return {
- ...fallback,
- id: moverId,
- };
-}
diff --git a/src/hooks/useFavoriteMover.ts b/src/hooks/useFavoriteMover.ts
index 44453423..4a4daf4f 100644
--- a/src/hooks/useFavoriteMover.ts
+++ b/src/hooks/useFavoriteMover.ts
@@ -16,6 +16,7 @@ import type {
ReceivedEstimatePanel,
} from "@/types/estimate";
import type { MoversListResult } from "@/types/mover";
+import type { MoverDetail } from "@/types/moverDetail";
const LOGIN_REQUIRED_MESSAGE = "로그인이 필요한 서비스입니다.";
@@ -44,6 +45,7 @@ interface FavoriteMutationContext {
previousPendingDetails: [readonly unknown[], EstimateDetail | undefined][];
previousMoverLists: [readonly unknown[], InfiniteData | undefined][];
previousFavoriteMovers: [readonly unknown[], MoversListResult | undefined][];
+ previousMoverDetail: MoverDetail | undefined;
}
function patchMoverFavorite(
@@ -78,6 +80,7 @@ async function invalidateFavoriteRelatedQueries(
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.ESTIMATE_REQUESTS.MY_LIST }),
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.ESTIMATES.PENDING_DETAIL_ROOT }),
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MOVERS.LIST }),
+ queryClient.invalidateQueries({ queryKey: [...QUERY_KEYS.MOVERS.ALL, "detail"] }),
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.FAVORITES.MOVERS }),
]);
}
@@ -119,10 +122,11 @@ export function useFavoriteMover(options?: UseFavoriteMoverOptions) {
queryClient.cancelQueries({ queryKey: QUERY_KEYS.ESTIMATE_REQUESTS.MY_LIST }),
queryClient.cancelQueries({ queryKey: QUERY_KEYS.ESTIMATES.PENDING_DETAIL_ROOT }),
queryClient.cancelQueries({ queryKey: QUERY_KEYS.MOVERS.LIST }),
+ queryClient.cancelQueries({ queryKey: QUERY_KEYS.MOVERS.DETAIL(moverId) }),
queryClient.cancelQueries({ queryKey: QUERY_KEYS.FAVORITES.MOVERS }),
]);
- // 롤백용 스냅샷 (received + pending + movers list + favorite movers)
+ // 롤백용 스냅샷 (received + pending + movers list + mover detail + favorite movers)
const previousReceived = queryClient.getQueryData(
QUERY_KEYS.ESTIMATES.RECEIVED,
);
@@ -141,6 +145,9 @@ export function useFavoriteMover(options?: UseFavoriteMoverOptions) {
const previousFavoriteMovers = queryClient.getQueriesData({
queryKey: QUERY_KEYS.FAVORITES.MOVERS,
});
+ const previousMoverDetail = queryClient.getQueryData(
+ QUERY_KEYS.MOVERS.DETAIL(moverId),
+ );
// 받은 견적 목록
queryClient.setQueryData(QUERY_KEYS.ESTIMATES.RECEIVED, (panels) => {
@@ -252,6 +259,15 @@ export function useFavoriteMover(options?: UseFavoriteMoverOptions) {
);
}
+ // 기사님 상세
+ queryClient.setQueryData(QUERY_KEYS.MOVERS.DETAIL(moverId), (detail) => {
+ if (!detail) {
+ return detail;
+ }
+
+ return patchMoverFavorite(detail, moverId, nextIsFavorite);
+ });
+
return {
previousReceived,
previousDetails,
@@ -259,9 +275,10 @@ export function useFavoriteMover(options?: UseFavoriteMoverOptions) {
previousPendingDetails,
previousMoverLists,
previousFavoriteMovers,
+ previousMoverDetail,
};
},
- onError: (error, _variables, context) => {
+ onError: (error, variables, context) => {
// 실패 시 낙관적 패치 전부 롤백
if (context) {
queryClient.setQueryData(QUERY_KEYS.ESTIMATES.RECEIVED, context.previousReceived);
@@ -280,6 +297,10 @@ export function useFavoriteMover(options?: UseFavoriteMoverOptions) {
context.previousFavoriteMovers.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
+ queryClient.setQueryData(
+ QUERY_KEYS.MOVERS.DETAIL(variables.moverId),
+ context.previousMoverDetail,
+ );
}
// Access만 있고 refresh 쿠키가 없는 잔여 세션 → 로그인 유도 (토큰 없음 메시지 대신)
diff --git a/src/hooks/useMoverDetail.ts b/src/hooks/useMoverDetail.ts
new file mode 100644
index 00000000..98869c1a
--- /dev/null
+++ b/src/hooks/useMoverDetail.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { getMoverDetail } from "@/lib/api/movers";
+import { QUERY_KEYS } from "@/lib/constants/queryKeys";
+import { isMoverDetailId } from "@/lib/utils/isMoverDetailId";
+import { mapMoverDetailItemToMoverDetail } from "@/lib/utils/mapMover";
+
+export function useMoverDetail(moverId: string) {
+ return useQuery({
+ queryKey: QUERY_KEYS.MOVERS.DETAIL(moverId),
+ queryFn: async () => {
+ const item = await getMoverDetail(moverId);
+ return mapMoverDetailItemToMoverDetail(item);
+ },
+ enabled: isMoverDetailId(moverId),
+ });
+}
diff --git a/src/hooks/useMoverReviews.ts b/src/hooks/useMoverReviews.ts
new file mode 100644
index 00000000..dd331e9a
--- /dev/null
+++ b/src/hooks/useMoverReviews.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+
+import { isMoverDetailId } from "@/lib/utils/isMoverDetailId";
+import { getMoverReviews, MOVER_REVIEW_PAGE_LIMIT } from "@/lib/api/movers";
+import { QUERY_KEYS } from "@/lib/constants/queryKeys";
+import type { MoverReviewListQuery } from "@/types/review";
+
+export function useMoverReviews(moverId: string, query: MoverReviewListQuery = {}) {
+ const page = query.page ?? 1;
+ const limit = query.limit ?? MOVER_REVIEW_PAGE_LIMIT;
+
+ return useQuery({
+ queryKey: [...QUERY_KEYS.REVIEWS.BY_MOVER(moverId), { page, limit }] as const,
+ queryFn: () => getMoverReviews(moverId, { page, limit }),
+ enabled: isMoverDetailId(moverId),
+ placeholderData: keepPreviousData,
+ });
+}
diff --git a/src/lib/api/movers.ts b/src/lib/api/movers.ts
index f9b6fdd5..abc37ce0 100644
--- a/src/lib/api/movers.ts
+++ b/src/lib/api/movers.ts
@@ -1,7 +1,16 @@
import fetchInstance from "@/lib/api/fetchInstance";
import { API_ROUTES } from "@/lib/constants/apiRoutes";
import { MOVERS_ALL_VALUE, MOVERS_PAGE_LIMIT } from "@/lib/utils/moversSearchParams";
-import type { MoverListItem, MoversListQuery, MoversListResult } from "@/types/mover";
+import type {
+ MoverDetailItem,
+ MoverListItem,
+ MoversListQuery,
+ MoversListResult,
+} from "@/types/mover";
+import type { MoverReviewItem, MoverReviewListQuery, MoverReviewListResult } from "@/types/review";
+
+/** 기사 상세 리뷰 목록 — 백엔드 listMoverReviewQuerySchema default */
+export const MOVER_REVIEW_PAGE_LIMIT = 5;
/** 백엔드 listMoverQuerySchema와 동일한 쿼리 키/값 */
function buildMoversQueryParams(query: MoversListQuery): URLSearchParams {
@@ -31,5 +40,34 @@ function buildMoversQueryParams(query: MoversListQuery): URLSearchParams {
/** GET /movers — 기사님 목록 (페이지네이션) */
export async function getMovers(query: MoversListQuery): Promise {
const params = buildMoversQueryParams(query);
- return fetchInstance.getPaginated(`${API_ROUTES.MOVERS}?${params.toString()}`);
+ return fetchInstance.getPaginated(
+ `${API_ROUTES.MOVERS.ROOT}?${params.toString()}`,
+ );
+}
+
+/** GET /movers/:moverId — 기사님 상세 */
+export async function getMoverDetail(moverId: string): Promise {
+ return fetchInstance.get(API_ROUTES.MOVERS.DETAIL(moverId));
+}
+
+/** GET /movers/:moverId/reviews — 기사님 리뷰 목록 */
+export async function getMoverReviews(
+ moverId: string,
+ query: MoverReviewListQuery = {},
+): Promise {
+ const page = query.page ?? 1;
+ const limit = query.limit ?? MOVER_REVIEW_PAGE_LIMIT;
+ const params = new URLSearchParams({
+ page: String(page),
+ limit: String(limit),
+ });
+
+ const result = await fetchInstance.getPaginated(
+ `${API_ROUTES.MOVERS.REVIEWS(moverId)}?${params.toString()}`,
+ );
+
+ return {
+ reviews: result.data,
+ pagination: result.pagination,
+ };
}
diff --git a/src/lib/constants/apiRoutes.ts b/src/lib/constants/apiRoutes.ts
index 723dea33..f04b00db 100644
--- a/src/lib/constants/apiRoutes.ts
+++ b/src/lib/constants/apiRoutes.ts
@@ -8,7 +8,11 @@ export const API_ROUTES = {
},
USERS: "/users",
PROFILES: "/profiles",
- MOVERS: "/movers",
+ MOVERS: {
+ ROOT: "/movers",
+ DETAIL: (moverId: string) => `/movers/${moverId}`,
+ REVIEWS: (moverId: string) => `/movers/${moverId}/reviews`,
+ },
ESTIMATE_REQUESTS: {
ROOT: "/estimate-requests",
DETAIL: (estimateRequestId: number) => `/estimate-requests/${estimateRequestId}`,
diff --git a/src/lib/constants/queryKeys.ts b/src/lib/constants/queryKeys.ts
index 55738a94..0e288529 100644
--- a/src/lib/constants/queryKeys.ts
+++ b/src/lib/constants/queryKeys.ts
@@ -46,6 +46,7 @@ export const QUERY_KEYS = {
ALL: ["reviews"] as const,
ME: ["reviews", "me"] as const,
REVIEWABLE: ["reviews", "reviewable"] as const,
+ BY_MOVER: (moverId: string) => ["reviews", "mover", moverId] as const,
},
NOTIFICATIONS: {
diff --git a/src/lib/utils/isMoverDetailId.ts b/src/lib/utils/isMoverDetailId.ts
new file mode 100644
index 00000000..26850f4a
--- /dev/null
+++ b/src/lib/utils/isMoverDetailId.ts
@@ -0,0 +1,6 @@
+const MOVER_ID_UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+export function isMoverDetailId(moverId: string): boolean {
+ return MOVER_ID_UUID_PATTERN.test(moverId);
+}
diff --git a/src/lib/utils/mapMover.ts b/src/lib/utils/mapMover.ts
index 2e65dbbd..6a95f24f 100644
--- a/src/lib/utils/mapMover.ts
+++ b/src/lib/utils/mapMover.ts
@@ -1,15 +1,42 @@
+import { REGION_LABEL, type RegionId } from "@/lib/constants/region";
import { resolveMoverProfileImageSrc } from "@/lib/utils/moverProfileImage";
-import type { Mover, MoverListItem } from "@/types/mover";
+import type {
+ Mover,
+ MoverDetailItem,
+ MoverListItem,
+ MoverRatingDistributionItem,
+} from "@/types/mover";
+import type { MoverDetail } from "@/types/moverDetail";
import type { MoveType } from "@/types/move";
+const RATING_SCORES = [5, 4, 3, 2, 1] as const;
+
+function isRegionId(value: number): value is RegionId {
+ return value in REGION_LABEL;
+}
+
+function mapServiceTypes(moveTypes: MoveType[]): MoveType[] {
+ return moveTypes.length > 0 ? moveTypes : (["SMALL"] as MoveType[]);
+}
+
+/** API 분포를 5→1점 순으로 맞추고, 없는 점수는 0으로 채웁니다. */
+function mapRatingDistribution(
+ items: MoverRatingDistributionItem[] | undefined,
+): MoverRatingDistributionItem[] {
+ const countByScore = new Map((items ?? []).map((item) => [item.score, item.count]));
+
+ return RATING_SCORES.map((score) => ({
+ score,
+ count: countByScore.get(score) ?? 0,
+ }));
+}
+
/** API 목록 아이템을 카드용 데이터로 변환 */
export function mapMoverListItemToMover(item: MoverListItem): Mover {
- const serviceTypes = item.moveTypes.length > 0 ? item.moveTypes : (["SMALL"] as MoveType[]);
-
return {
id: item.id,
name: item.nickname,
- serviceTypes,
+ serviceTypes: mapServiceTypes(item.moveTypes),
// 목록 API는 serviceAreas를 주지 않음 (상세 API에만 존재)
serviceAreas: [],
title: item.shortIntro,
@@ -23,3 +50,14 @@ export function mapMoverListItemToMover(item: MoverListItem): Mover {
profileImageSrc: resolveMoverProfileImageSrc(item.profileImageUrl),
};
}
+
+/** API 상세 응답을 상세 화면용 데이터로 변환 */
+export function mapMoverDetailItemToMoverDetail(item: MoverDetailItem): MoverDetail {
+ const base = mapMoverListItemToMover(item);
+
+ return {
+ ...base,
+ serviceAreas: item.serviceAreas.map((area) => area.id).filter(isRegionId),
+ ratingDistribution: mapRatingDistribution(item.ratingDistribution),
+ };
+}
diff --git a/src/types/mover.ts b/src/types/mover.ts
index 479a7005..df8bb4ec 100644
--- a/src/types/mover.ts
+++ b/src/types/mover.ts
@@ -40,6 +40,24 @@ export interface MoverListItem {
isFavorite: boolean;
}
+/** GET /movers/:moverId 상세 — 목록 필드 + 서비스 가능 지역 */
+export interface MoverDetailServiceArea {
+ id: number;
+ name: string;
+}
+
+/** 별점 분포 항목 (5점 → 1점) */
+export interface MoverRatingDistributionItem {
+ score: number;
+ count: number;
+}
+
+export interface MoverDetailItem extends MoverListItem {
+ serviceAreas: MoverDetailServiceArea[];
+ /** 리뷰 별점 분포 요약 (상세 API) */
+ ratingDistribution: MoverRatingDistributionItem[];
+}
+
export interface MoversPagination {
page: number;
limit: number;
diff --git a/src/types/moverDetail.ts b/src/types/moverDetail.ts
index 9b2ad89f..9cd952ba 100644
--- a/src/types/moverDetail.ts
+++ b/src/types/moverDetail.ts
@@ -1,7 +1,11 @@
import type { MoveType } from "@/types/move";
-import type { Mover } from "@/types/mover";
+import type { Mover, MoverRatingDistributionItem } from "@/types/mover";
-/** 기사님 상세 목업용 리뷰 아이템 */
+export type { MoverRatingDistributionItem };
+
+/**
+ * 기사님 상세 화면의 리뷰 목록 아이템 (UI)
+ */
export interface MoverDetailReview {
id: string;
authorMasked: string;
@@ -10,20 +14,13 @@ export interface MoverDetailReview {
content: string;
}
-/** 별점 분포 (5점 → 1점) */
-export interface MoverRatingDistributionItem {
- score: number;
- count: number;
-}
-
/**
- * 기사님 상세 화면 데이터 (API 연동 전 목업)
- * 목록용 `Mover`에 상세 전용 필드를 확장합니다.
+ * 기사님 상세 화면 데이터
+ * 리뷰 목록은 `GET /movers/:id/reviews`로 별도 조회합니다.
+ * 별점 분포는 상세 API `ratingDistribution`을 사용합니다.
*/
export interface MoverDetail extends Mover {
/** 제공 서비스 (칩 복수) */
serviceTypes: MoveType[];
ratingDistribution: MoverRatingDistributionItem[];
- reviews: MoverDetailReview[];
- reviewPageCount: number;
}
diff --git a/src/types/review.ts b/src/types/review.ts
index b688b42b..466f4927 100644
--- a/src/types/review.ts
+++ b/src/types/review.ts
@@ -64,6 +64,36 @@ export interface MyReviewListQuery {
limit?: number;
}
+/**
+ * 백엔드 `GET /movers/:moverId/reviews` 응답 아이템
+ */
+export interface MoverReviewItem {
+ id: number;
+ rating: number;
+ content: string;
+ createdAt: string;
+ customer: {
+ id: string;
+ displayName: string;
+ imageUrl: string | null;
+ };
+ estimateRequest: {
+ id: number;
+ moveType: MoveType;
+ moveDate: string;
+ };
+}
+
+export interface MoverReviewListResult {
+ reviews: MoverReviewItem[];
+ pagination: Pagination;
+}
+
+export interface MoverReviewListQuery {
+ page?: number;
+ limit?: number;
+}
+
export interface CreateReviewInput {
estimateId: number;
rating: number;