diff --git a/src/app/favorites/movers/page.tsx b/src/app/favorites/movers/page.tsx index ab175251..c3e5d812 100644 --- a/src/app/favorites/movers/page.tsx +++ b/src/app/favorites/movers/page.tsx @@ -32,7 +32,7 @@ export default function FavoriteMoversPage() { } buttonLabel="기사님 찾기로 돌아가기" - href={APP_ROUTES.MOVERS} + href={APP_ROUTES.MOVERS.ROOT} /> diff --git a/src/app/movers/[moverId]/error.tsx b/src/app/movers/[moverId]/error.tsx new file mode 100644 index 00000000..38fc11b3 --- /dev/null +++ b/src/app/movers/[moverId]/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; + +import MoversErrorPanel from "@/components/mover/MoversErrorPanel"; + +interface MoverDetailErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function MoverDetailError({ error, reset }: MoverDetailErrorProps) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+ +
+ ); +} diff --git a/src/app/movers/[moverId]/not-found.tsx b/src/app/movers/[moverId]/not-found.tsx new file mode 100644 index 00000000..daa04b16 --- /dev/null +++ b/src/app/movers/[moverId]/not-found.tsx @@ -0,0 +1,5 @@ +import MoverDetailNotFoundStatus from "@/components/mover/detail/MoverDetailNotFoundStatus"; + +export default function MoverDetailNotFound() { + return ; +} diff --git a/src/app/movers/[moverId]/page.tsx b/src/app/movers/[moverId]/page.tsx index b4006449..bdae95e5 100644 --- a/src/app/movers/[moverId]/page.tsx +++ b/src/app/movers/[moverId]/page.tsx @@ -1,25 +1,24 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import MoverDetailView from "@/components/mover/detail/MoverDetailView"; -import { getMockMoverDetail } from "@/components/mover/detail/moverDetailMock"; +import { isMoverDetailId } from "@/lib/utils/isMoverDetailId"; interface MoverDetailPageProps { params: Promise<{ moverId: string }>; } -export async function generateMetadata({ params }: MoverDetailPageProps): Promise { - const { moverId } = await params; - const detail = getMockMoverDetail(moverId); - const name = detail?.name ?? "기사님"; - - return { - title: `${name} 기사님 상세`, - description: detail?.title ?? "이사 기사님 상세 정보를 확인하세요.", - }; -} +export const metadata: Metadata = { + title: "기사님 상세", + description: "이사 기사님 상세 정보를 확인하세요.", +}; export default async function MoverDetailPage({ params }: MoverDetailPageProps) { const { moverId } = await params; + if (!isMoverDetailId(moverId)) { + notFound(); + } + return ; } diff --git a/src/components/mover/MoverCard.tsx b/src/components/mover/MoverCard.tsx index 23f1bcc2..4be99927 100644 --- a/src/components/mover/MoverCard.tsx +++ b/src/components/mover/MoverCard.tsx @@ -1,15 +1,17 @@ "use client"; +import Link from "next/link"; + import { Text } from "@/components/common/Text"; +import { MoverMeta } from "@/components/mover/MoverMeta"; +import { MoverProfileImage } from "@/components/mover/MoverProfileImage"; +import { MoverServiceTypeChips } from "@/components/mover/MoverServiceTypeChips"; import { useFavoriteMover } from "@/hooks/useFavoriteMover"; import { DriverBadgeIcon, LikeIcon } from "@/icons"; +import { APP_ROUTES } from "@/lib/constants/appRoutes"; import { cn } from "@/lib/utils/cn"; import type { Mover } from "@/types/mover"; -import { MoverMeta } from "@/components/mover/MoverMeta"; -import { MoverProfileImage } from "@/components/mover/MoverProfileImage"; -import { MoverServiceTypeChips } from "@/components/mover/MoverServiceTypeChips"; - interface MoverCardProps { mover: Mover; variant?: "full" | "compact"; @@ -35,13 +37,17 @@ function FavoriteButton({ onToggle, }: FavoriteButtonProps) { return ( -
+
+
+ ); +} 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;