From 7057248839e31665e2082073d8c10638d777f8de Mon Sep 17 00:00:00 2001 From: jeungseulki Date: Mon, 27 Jul 2026 18:48:40 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=C2=B7API=C2=B7mock=C2=B7hooks=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성 가능/내 리뷰 조회와 리뷰 작성 흐름을 mock 우선으로 연결할 수 있게 데이터 레이어를 구성합니다. Co-authored-by: Cursor --- src/hooks/useCreateReview.ts | 32 +++ src/hooks/useMyReviews.ts | 19 ++ src/hooks/useReviewableEstimates.ts | 14 ++ src/lib/api/reviews.ts | 83 ++++++++ src/lib/mocks/reviews.mock.ts | 292 ++++++++++++++++++++++++++++ src/types/review.ts | 80 ++++++++ 6 files changed, 520 insertions(+) create mode 100644 src/hooks/useCreateReview.ts create mode 100644 src/hooks/useMyReviews.ts create mode 100644 src/hooks/useReviewableEstimates.ts create mode 100644 src/lib/api/reviews.ts create mode 100644 src/lib/mocks/reviews.mock.ts create mode 100644 src/types/review.ts diff --git a/src/hooks/useCreateReview.ts b/src/hooks/useCreateReview.ts new file mode 100644 index 00000000..ae006a6b --- /dev/null +++ b/src/hooks/useCreateReview.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { createReview } from "@/lib/api/reviews"; +import { getApiErrorMessage } from "@/lib/api/getApiErrorMessage"; +import { QUERY_KEYS } from "@/lib/constants/queryKeys"; +import type { CreateReviewInput } from "@/types/review"; + +interface UseCreateReviewOptions { + onSuccess?: () => void; + onError?: (message: string) => void; +} + +// 2026.07.27 정슬기 - [추가] 리뷰 작성 mutation + ME/REVIEWABLE invalidate +export function useCreateReview(options: UseCreateReviewOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateReviewInput) => createReview(input), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.REVIEWS.ME }), + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.REVIEWS.REVIEWABLE }), + ]); + options.onSuccess?.(); + }, + onError: (error) => { + options.onError?.(getApiErrorMessage(error, "리뷰 등록에 실패했습니다.")); + }, + }); +} diff --git a/src/hooks/useMyReviews.ts b/src/hooks/useMyReviews.ts new file mode 100644 index 00000000..1d094dd7 --- /dev/null +++ b/src/hooks/useMyReviews.ts @@ -0,0 +1,19 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import { fetchMyReviews, MY_REVIEW_PAGE_LIMIT } from "@/lib/api/reviews"; +import { QUERY_KEYS } from "@/lib/constants/queryKeys"; +import type { MyReviewListQuery } from "@/types/review"; + +// 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 목록 조회 +export function useMyReviews(query: MyReviewListQuery = {}) { + const page = query.page ?? 1; + const limit = query.limit ?? MY_REVIEW_PAGE_LIMIT; + + return useQuery({ + queryKey: [...QUERY_KEYS.REVIEWS.ME, { page, limit }] as const, + queryFn: () => fetchMyReviews({ page, limit }), + placeholderData: keepPreviousData, + }); +} diff --git a/src/hooks/useReviewableEstimates.ts b/src/hooks/useReviewableEstimates.ts new file mode 100644 index 00000000..9393f76a --- /dev/null +++ b/src/hooks/useReviewableEstimates.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { fetchReviewableEstimates } from "@/lib/api/reviews"; +import { QUERY_KEYS } from "@/lib/constants/queryKeys"; + +// 2026.07.27 정슬기 - [추가] 작성 가능 리뷰 전체 목록 조회 (FE 페이지네이션) +export function useReviewableEstimates() { + return useQuery({ + queryKey: QUERY_KEYS.REVIEWS.REVIEWABLE, + queryFn: fetchReviewableEstimates, + }); +} diff --git a/src/lib/api/reviews.ts b/src/lib/api/reviews.ts new file mode 100644 index 00000000..707d7ea9 --- /dev/null +++ b/src/lib/api/reviews.ts @@ -0,0 +1,83 @@ +import { + createMockReview, + getMockMyReviews, + getMockReviewableEstimates, +} from "@/lib/mocks/reviews.mock"; +import type { + CreatedReview, + CreateReviewInput, + MyReviewListQuery, + MyReviewListResult, + ReviewableEstimateItem, +} from "@/types/review"; + +export const REVIEWABLE_PAGE_LIMIT = 5; +export const MY_REVIEW_PAGE_LIMIT = 5; + +/** + * 작성 가능한 리뷰(확정·완료·미작성) 전체 목록을 조회합니다. + * 백엔드는 페이지네이션을 지원하지 않으므로 FE에서 limit 단위로 슬라이스합니다. + * + * 현재는 mock을 반환하며, 이후 axios API 호출로 교체하면 됩니다. + * // 2026.07.27 정슬기 - [추가] 작성 가능 리뷰 service (mock) + */ +export async function fetchReviewableEstimates(): Promise { + // TODO: 실제 API 연동 시 아래 mock 블록을 제거하고 axios 호출로 교체 + // const { data } = await axiosInstance.get>( + // API_ROUTES.REVIEWS.REVIEWABLE, + // ); + // return data.data; + + await Promise.resolve(); + return getMockReviewableEstimates(); +} + +/** + * 내가 작성한 리뷰 목록을 서버 페이지네이션으로 조회합니다. + * + * 현재는 mock을 반환하며, 이후 axios API 호출로 교체하면 됩니다. + * // 2026.07.27 정슬기 - [추가] 내 리뷰 목록 service (mock) + * // 2026.07.27 정슬기 - [수정] 범위 밖 page 재요청 보정 주석 추가 + */ +export async function fetchMyReviews(query: MyReviewListQuery = {}): Promise { + const page = query.page ?? 1; + const limit = query.limit ?? MY_REVIEW_PAGE_LIMIT; + + // TODO: 실제 API 연동 시 아래 mock 블록을 제거하고 axios 호출로 교체 + // const { data } = await axiosInstance.get>( + // API_ROUTES.REVIEWS.ME, + // { params: { page, limit } }, + // ); + // let result = { reviews: data.data, pagination: data.pagination }; + // // 백엔드 totalPages=0/범위 밖 page 대응: 빈 페이지면 마지막 페이지로 재요청 + // const totalPages = Math.max(1, result.pagination.totalPages); + // if (result.pagination.totalCount > 0 && result.reviews.length === 0 && page > totalPages) { + // const retry = await axiosInstance.get>( + // API_ROUTES.REVIEWS.ME, + // { params: { page: totalPages, limit } }, + // ); + // result = { reviews: retry.data.data, pagination: retry.data.pagination }; + // } + // return result; + + await Promise.resolve(); + return getMockMyReviews({ page, limit }); +} + +/** + * 확정·완료 견적에 대한 리뷰를 등록합니다. + * + * 현재는 mock을 갱신하며, 이후 axios API 호출로 교체하면 됩니다. + * // 2026.07.27 정슬기 - [추가] 리뷰 작성 service (mock) + */ +export async function createReview(input: CreateReviewInput): Promise { + // TODO: 실제 API 연동 시 아래 mock 블록을 제거하고 axios 호출로 교체 + // const { data } = await axiosInstance.post>( + // API_ROUTES.REVIEWS.ROOT, + // input, + // ); + // return data.data; + + await Promise.resolve(); + return createMockReview(input); +} diff --git a/src/lib/mocks/reviews.mock.ts b/src/lib/mocks/reviews.mock.ts new file mode 100644 index 00000000..f7ad698a --- /dev/null +++ b/src/lib/mocks/reviews.mock.ts @@ -0,0 +1,292 @@ +import { buildMockPagination } from "@/lib/mocks/pagination"; +import type { + CreatedReview, + CreateReviewInput, + MyReviewItem, + MyReviewListQuery, + MyReviewListResult, + ReviewableEstimateItem, +} from "@/types/review"; + +/** + * 리뷰 mock 데이터 + * UI/훅에서는 직접 import하지 말고 `src/lib/api/reviews.ts` service만 사용합니다. + * // 2026.07.27 정슬기 - [추가] 리뷰 mock (API 교체 전 확인용) + */ + +let nextReviewId = 9001; + +let mockReviewableEstimates: ReviewableEstimateItem[] = [ + { + estimateId: 501, + price: 180000, + confirmedAt: "2026-06-20T03:00:00.000Z", + estimateRequest: { + id: 201, + moveType: "SMALL", + moveDate: "2026-06-18", + fromAddress: "서울 중구 을지로 100", + toAddress: "경기 성남시 분당구 정자일로 95", + status: "COMPLETED", + }, + mover: { + id: "mover-kim", + nickname: "김코드", + imageUrl: null, + career: 7, + averageRating: 4.8, + reviewCount: 128, + }, + }, + { + estimateId: 502, + price: 320000, + confirmedAt: "2026-06-12T05:30:00.000Z", + estimateRequest: { + id: 202, + moveType: "HOME", + moveDate: "2026-06-10", + fromAddress: "서울 마포구 월드컵북로 396", + toAddress: "인천 연수구 센트럴로 123", + status: "COMPLETED", + }, + mover: { + id: "mover-lee", + nickname: "이이사", + imageUrl: null, + career: 5, + averageRating: 4.5, + reviewCount: 86, + }, + }, + { + estimateId: 503, + price: 450000, + confirmedAt: "2026-05-28T02:10:00.000Z", + estimateRequest: { + id: 203, + moveType: "OFFICE", + moveDate: "2026-05-25", + fromAddress: "서울 강남구 테헤란로 152", + toAddress: "서울 송파구 올림픽로 300", + status: "COMPLETED", + }, + mover: { + id: "mover-park", + nickname: "박안전", + imageUrl: null, + career: 10, + averageRating: 4.9, + reviewCount: 210, + }, + }, + { + estimateId: 504, + price: 210000, + confirmedAt: "2026-05-15T08:00:00.000Z", + estimateRequest: { + id: 204, + moveType: "SMALL", + moveDate: "2026-05-12", + fromAddress: "경기 고양시 일산동구 중앙로 1275", + toAddress: "서울 은평구 통일로 480", + status: "COMPLETED", + }, + mover: { + id: "mover-choi", + nickname: "최친절", + imageUrl: null, + career: 3, + averageRating: 4.2, + reviewCount: 42, + }, + }, + { + estimateId: 505, + price: 380000, + confirmedAt: "2026-05-02T01:20:00.000Z", + estimateRequest: { + id: 205, + moveType: "HOME", + moveDate: "2026-04-30", + fromAddress: "부산 해운대구 센텀중앙로 90", + toAddress: "부산 수영구 광안해변로 219", + status: "COMPLETED", + }, + mover: { + id: "mover-jung", + nickname: "정든손", + imageUrl: null, + career: 8, + averageRating: 4.7, + reviewCount: 155, + }, + }, + { + estimateId: 506, + price: 290000, + confirmedAt: "2026-04-18T06:40:00.000Z", + estimateRequest: { + id: 206, + moveType: "HOME", + moveDate: "2026-04-15", + fromAddress: "대전 유성구 대학로 99", + toAddress: "세종특별자치시 한누리대로 2130", + status: "COMPLETED", + }, + mover: { + id: "mover-han", + nickname: "한믿음", + imageUrl: null, + career: 6, + averageRating: 4.4, + reviewCount: 73, + }, + }, +]; + +let mockMyReviews: MyReviewItem[] = [ + { + id: 8001, + estimateId: 401, + rating: 5, + content: + "기사님이 시간 약속도 잘 지켜주시고, 짐도 조심히 옮겨주셨어요. 다음에도 부탁드리고 싶습니다.", + createdAt: "2026-06-01T10:20:00.000Z", + price: 250000, + estimateRequest: { + id: 111, + moveType: "HOME", + moveDate: "2026-05-28", + fromAddress: "서울 서초구 서초대로 396", + toAddress: "경기 용인시 수지구 광교중앙로 295", + }, + mover: { + id: "mover-kim", + name: "김코드", + nickname: "김코드", + imageUrl: null, + shortIntro: "안전하고 빠른 이사", + }, + }, + { + id: 8002, + estimateId: 402, + rating: 4, + content: "전반적으로 만족스러웠습니다. 다만 도착 후 정리까지는 조금 더 꼼꼼했으면 좋겠어요.", + createdAt: "2026-05-10T08:15:00.000Z", + price: 170000, + estimateRequest: { + id: 112, + moveType: "SMALL", + moveDate: "2026-05-08", + fromAddress: "서울 영등포구 여의대로 108", + toAddress: "서울 동작구 흑석로 47", + }, + mover: { + id: "mover-lee", + name: "이이사", + nickname: "이이사", + imageUrl: null, + shortIntro: null, + }, + }, + { + id: 8003, + estimateId: 403, + rating: 5, + content: "사무실 이사였는데 동선 파악이 빠르고 포장도 깔끔했습니다. 추천합니다!", + createdAt: "2026-04-22T12:00:00.000Z", + price: 520000, + estimateRequest: { + id: 113, + moveType: "OFFICE", + moveDate: "2026-04-20", + fromAddress: "서울 종로구 종로 1", + toAddress: "서울 성동구 왕십리로 222", + }, + mover: { + id: "mover-park", + name: "박안전", + nickname: "박안전", + imageUrl: null, + shortIntro: "사무실 이사 전문", + }, + }, +]; + +/** 테스트용: 내가 작성한 리뷰를 비울 때 사용 */ +export function resetMockMyReviews(reviews: MyReviewItem[] = []) { + mockMyReviews = reviews; +} + +export function resetMockReviewableEstimates(items: ReviewableEstimateItem[]) { + mockReviewableEstimates = items; +} + +export function getMockReviewableEstimates(): ReviewableEstimateItem[] { + return mockReviewableEstimates; +} + +export function getMockMyReviews(query: MyReviewListQuery = {}): MyReviewListResult { + const limit = query.limit ?? 5; + const sorted = [...mockMyReviews].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + const totalCount = sorted.length; + const totalPages = Math.max(1, Math.ceil(totalCount / limit) || 1); + // 요청 page가 범위를 벗어나면 마지막 페이지로 보정 (빈 화면 방지) + const page = totalCount === 0 ? 1 : Math.min(Math.max(1, query.page ?? 1), totalPages); + const start = (page - 1) * limit; + + return { + reviews: sorted.slice(start, start + limit), + pagination: buildMockPagination(totalCount, page, limit), + }; +} + +export function createMockReview(input: CreateReviewInput): CreatedReview { + const target = mockReviewableEstimates.find((item) => item.estimateId === input.estimateId); + + if (!target) { + throw new Error("작성 가능한 견적을 찾을 수 없습니다."); + } + + const createdAt = new Date().toISOString(); + const review: MyReviewItem = { + id: nextReviewId, + estimateId: input.estimateId, + rating: input.rating, + content: input.content, + createdAt, + price: target.price, + estimateRequest: { + id: target.estimateRequest.id, + moveType: target.estimateRequest.moveType, + moveDate: target.estimateRequest.moveDate, + fromAddress: target.estimateRequest.fromAddress, + toAddress: target.estimateRequest.toAddress, + }, + mover: { + id: target.mover.id, + name: target.mover.nickname ?? "기사님", + nickname: target.mover.nickname, + imageUrl: target.mover.imageUrl, + shortIntro: null, + }, + }; + + nextReviewId += 1; + mockMyReviews = [review, ...mockMyReviews]; + mockReviewableEstimates = mockReviewableEstimates.filter( + (item) => item.estimateId !== input.estimateId, + ); + + return { + id: review.id, + estimateId: review.estimateId, + rating: review.rating, + content: review.content, + createdAt: review.createdAt, + }; +} diff --git a/src/types/review.ts b/src/types/review.ts new file mode 100644 index 00000000..b688b42b --- /dev/null +++ b/src/types/review.ts @@ -0,0 +1,80 @@ +import type { MoveType } from "@/types/move"; +import type { Pagination } from "@/types/pagination"; + +/** + * 백엔드 `GET /reviews/reviewable` 응답 아이템 + * // 2026.07.27 정슬기 - [추가] 작성 가능 리뷰 API 타입 + */ +export interface ReviewableEstimateItem { + estimateId: number; + price: number; + confirmedAt: string | null; + estimateRequest: { + id: number; + moveType: MoveType; + moveDate: string; + fromAddress: string; + toAddress: string; + status: string; + }; + mover: { + id: string; + nickname: string | null; + imageUrl: string | null; + career: number | null; + averageRating: number | null; + reviewCount: number | null; + }; +} + +/** + * 백엔드 `GET /reviews/me` 응답 아이템 + * // 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 API 타입 + */ +export interface MyReviewItem { + id: number; + estimateId: number; + rating: number; + content: string; + createdAt: string; + price: number; + estimateRequest: { + id: number; + moveType: MoveType; + moveDate: string; + fromAddress: string; + toAddress: string; + }; + mover: { + id: string; + name: string; + nickname: string | null; + imageUrl: string | null; + shortIntro: string | null; + }; +} + +export interface MyReviewListResult { + reviews: MyReviewItem[]; + pagination: Pagination; +} + +export interface MyReviewListQuery { + page?: number; + limit?: number; +} + +export interface CreateReviewInput { + estimateId: number; + rating: number; + content: string; +} + +/** `POST /reviews` 생성 응답 */ +export interface CreatedReview { + id: number; + estimateId: number; + rating: number; + content: string; + createdAt: string; +} From 61609fb67c98b26d6f761497b6ae357a4e1c7d66 Mon Sep 17 00:00:00 2001 From: jeungseulki Date: Mon, 27 Jul 2026 18:52:31 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B4=80=EB=A6=AC=20UI=20=EB=B0=8F=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성 가능 리뷰·내가 작성한 리뷰 목록과 작성 모달을 견적 페이지 패턴에 맞춰 구성합니다. Co-authored-by: Cursor --- src/app/reviews/layout.tsx | 14 ++ src/app/reviews/me/page.tsx | 12 + src/app/reviews/page.tsx | 8 + src/app/reviews/writable/page.tsx | 12 + src/components/review/MyReviewCard.tsx | 106 ++++++++ src/components/review/MyReviewsPageClient.tsx | 79 ++++++ src/components/review/ReviewEmptyState.tsx | 37 +++ src/components/review/ReviewPageFrame.tsx | 35 +++ src/components/review/ReviewStarRating.tsx | 86 +++++++ src/components/review/ReviewTabs.tsx | 51 ++++ src/components/review/ReviewWriteModal.tsx | 229 ++++++++++++++++++ src/components/review/ReviewsShell.tsx | 19 ++ src/components/review/WritableReviewCard.tsx | 173 +++++++++++++ .../review/WritableReviewsPageClient.tsx | 102 ++++++++ 14 files changed, 963 insertions(+) create mode 100644 src/app/reviews/layout.tsx create mode 100644 src/app/reviews/me/page.tsx create mode 100644 src/app/reviews/page.tsx create mode 100644 src/app/reviews/writable/page.tsx create mode 100644 src/components/review/MyReviewCard.tsx create mode 100644 src/components/review/MyReviewsPageClient.tsx create mode 100644 src/components/review/ReviewEmptyState.tsx create mode 100644 src/components/review/ReviewPageFrame.tsx create mode 100644 src/components/review/ReviewStarRating.tsx create mode 100644 src/components/review/ReviewTabs.tsx create mode 100644 src/components/review/ReviewWriteModal.tsx create mode 100644 src/components/review/ReviewsShell.tsx create mode 100644 src/components/review/WritableReviewCard.tsx create mode 100644 src/components/review/WritableReviewsPageClient.tsx diff --git a/src/app/reviews/layout.tsx b/src/app/reviews/layout.tsx new file mode 100644 index 00000000..123f5984 --- /dev/null +++ b/src/app/reviews/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +import ReviewsShell from "@/components/review/ReviewsShell"; + +export const metadata: Metadata = { + title: "리뷰 관리", + description: "작성 가능한 리뷰와 내가 작성한 리뷰를 관리합니다.", +}; + +// 2026.07.27 정슬기 - [추가] 리뷰 관리 레이아웃 +export default function ReviewsLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/reviews/me/page.tsx b/src/app/reviews/me/page.tsx new file mode 100644 index 00000000..71e45f59 --- /dev/null +++ b/src/app/reviews/me/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import MyReviewsPageClient from "@/components/review/MyReviewsPageClient"; + +export const metadata: Metadata = { + title: "내가 작성한 리뷰", +}; + +// 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 페이지 +export default function MyReviewsPage() { + return ; +} diff --git a/src/app/reviews/page.tsx b/src/app/reviews/page.tsx new file mode 100644 index 00000000..028df5a3 --- /dev/null +++ b/src/app/reviews/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +import { APP_ROUTES } from "@/lib/constants/appRoutes"; + +// 2026.07.27 정슬기 - [추가] /reviews → 작성 가능 리뷰로 이동 +export default function ReviewsIndexPage() { + redirect(APP_ROUTES.REVIEWS.WRITABLE); +} diff --git a/src/app/reviews/writable/page.tsx b/src/app/reviews/writable/page.tsx new file mode 100644 index 00000000..0cda187f --- /dev/null +++ b/src/app/reviews/writable/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import WritableReviewsPageClient from "@/components/review/WritableReviewsPageClient"; + +export const metadata: Metadata = { + title: "작성 가능한 리뷰", +}; + +// 2026.07.27 정슬기 - [추가] 작성 가능한 리뷰 페이지 +export default function WritableReviewsPage() { + return ; +} diff --git a/src/components/review/MyReviewCard.tsx b/src/components/review/MyReviewCard.tsx new file mode 100644 index 00000000..4ec92769 --- /dev/null +++ b/src/components/review/MyReviewCard.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Image from "next/image"; + +import { Text } from "@/components/common/Text"; +import { MoveTypeChip } from "@/components/estimate/received/MoveTypeChip"; +import ReviewStarRating from "@/components/review/ReviewStarRating"; +import { ProfileDefaultIcon } from "@/icons"; +import { formatKoreanDateLong } from "@/lib/utils/estimateFormat"; +import type { MyReviewItem } from "@/types/review"; + +interface MyReviewCardProps { + review: MyReviewItem; +} + +// 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 카드 +// 2026.07.27 정슬기 - [수정] Mobile 주소 줄바꿈·타이포 / Desktop 작성일 우측 배치 +export default function MyReviewCard({ review }: MyReviewCardProps) { + const { mover, estimateRequest, rating, content, createdAt } = review; + const displayName = mover.nickname?.trim() || mover.name; + const titleId = `my-review-${review.id}-title`; + + return ( +
+
+
+ + +
+ +
+
+ {mover.imageUrl ? ( + {`${displayName} + ) : ( + + )} +
+ +
+
+ + {displayName} 기사님 + + + {formatKoreanDateLong(createdAt)} + +
+ + {estimateRequest.fromAddress} + + 에서 + {estimateRequest.toAddress} + +
+
+
+ + + + + {content} + + + + {formatKoreanDateLong(createdAt)} + +
+ ); +} diff --git a/src/components/review/MyReviewsPageClient.tsx b/src/components/review/MyReviewsPageClient.tsx new file mode 100644 index 00000000..a8f32ed0 --- /dev/null +++ b/src/components/review/MyReviewsPageClient.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; + +import Pagination from "@/components/common/Pagination/Pagination"; +import ReceivedEstimatesStatus from "@/components/estimate/received/ReceivedEstimatesStatus"; +import MyReviewCard from "@/components/review/MyReviewCard"; +import ReviewEmptyState from "@/components/review/ReviewEmptyState"; +import ReviewPageFrame from "@/components/review/ReviewPageFrame"; +import { useMyReviews } from "@/hooks/useMyReviews"; +import { getApiErrorMessage } from "@/lib/api/getApiErrorMessage"; +import { MY_REVIEW_PAGE_LIMIT } from "@/lib/api/reviews"; + +// 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 목록 Page Client +// 2026.07.27 정슬기 - [수정] 페이지 범위 보정은 service/mock에서 처리, UI는 파생값만 사용 +export default function MyReviewsPageClient() { + const [page, setPage] = useState(1); + const { data, isLoading, isError, error, refetch, isFetching } = useMyReviews({ + page, + limit: MY_REVIEW_PAGE_LIMIT, + }); + + const reviews = data?.reviews ?? []; + const pagination = data?.pagination; + const totalCount = pagination?.totalCount ?? 0; + const totalPages = Math.max(1, pagination?.totalPages ?? 1); + // 응답 pagination.page를 우선해 표시 (service가 보정한 페이지) + const currentPage = pagination?.page ?? Math.min(Math.max(1, page), totalPages); + const isEmpty = !isLoading && !isError && Boolean(pagination) && totalCount === 0; + const hasList = !isLoading && !isError && reviews.length > 0; + + const handlePageChange = (nextPage: number) => { + setPage(nextPage); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + return ( + + {isLoading ? : null} + + {isError ? ( + { + void refetch(); + }} + /> + ) : null} + + {isEmpty ? : null} + + {hasList && pagination ? ( +
+
    + {reviews.map((review) => ( +
  • + +
  • + ))} +
+ + {totalPages > 1 ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/components/review/ReviewEmptyState.tsx b/src/components/review/ReviewEmptyState.tsx new file mode 100644 index 00000000..542b07f8 --- /dev/null +++ b/src/components/review/ReviewEmptyState.tsx @@ -0,0 +1,37 @@ +import EmptyState from "@/components/common/EmptyState/EmptyState"; + +type ReviewEmptyVariant = "writable" | "my"; + +interface ReviewEmptyStateProps { + variant: ReviewEmptyVariant; +} + +const EMPTY_COPY: Record = { + writable: { + line1: "작성 가능한 리뷰가 없습니다.", + line2: "이사가 완료되면 기사님에 대한 리뷰를 작성할 수 있어요.", + }, + my: { + line1: "아직 작성한 리뷰가 없습니다.", + line2: "이용한 기사님에 대한 경험을 남겨보세요.", + }, +}; + +// 2026.07.27 정슬기 - [추가] 리뷰 목록 빈 상태 +export default function ReviewEmptyState({ variant }: ReviewEmptyStateProps) { + const copy = EMPTY_COPY[variant]; + + return ( + + {copy.line1} +
+ {copy.line2} + + } + imageAlt="" + /> + ); +} diff --git a/src/components/review/ReviewPageFrame.tsx b/src/components/review/ReviewPageFrame.tsx new file mode 100644 index 00000000..9bcb1e3e --- /dev/null +++ b/src/components/review/ReviewPageFrame.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +import { Text } from "@/components/common/Text"; +import { cn } from "@/lib/utils/cn"; + +interface ReviewPageFrameProps { + title: string; + children: ReactNode; + className?: string; +} + +/** + * 리뷰 목록 페이지 공통 프레임 (제목 + 콘텐츠 폭/여백) + * // 2026.07.27 정슬기 - [추가] Mobile/Tablet/Desktop 여백·타이포 통일 + */ +export default function ReviewPageFrame({ title, children, className }: ReviewPageFrameProps) { + return ( +
+
+ + {title} + +
+ +
+ {children} +
+
+ ); +} diff --git a/src/components/review/ReviewStarRating.tsx b/src/components/review/ReviewStarRating.tsx new file mode 100644 index 00000000..19e62337 --- /dev/null +++ b/src/components/review/ReviewStarRating.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { StarIcon } from "@/icons"; +import { cn } from "@/lib/utils/cn"; + +interface ReviewStarRatingProps { + /** 1~5 별점. 표시 전용일 때 현재 값, 선택형일 때 선택된 값 */ + value: number; + /** 전달되면 별점 선택 UI로 동작 */ + onChange?: (rating: number) => void; + size?: "sm" | "md" | "lg"; + className?: string; + /** 스크린 리더용 라벨 prefix */ + label?: string; + disabled?: boolean; +} + +const SIZE_CLASS = { + sm: "size-20 md:size-24", + md: "size-24", + lg: "size-28 md:size-32", +} as const; + +/** + * 별점 표시/선택 공통 컴포넌트 + * // 2026.07.27 정슬기 - [추가] 리뷰 별점 UI + */ +export default function ReviewStarRating({ + value, + onChange, + size = "md", + className, + label = "별점", + disabled = false, +}: ReviewStarRatingProps) { + const isInteractive = typeof onChange === "function"; + const clamped = Math.min(5, Math.max(0, value)); + + return ( +
+ {Array.from({ length: 5 }, (_, index) => { + const starValue = index + 1; + const isFilled = starValue <= clamped; + + if (!isInteractive) { + return ( +
+ ); +} diff --git a/src/components/review/ReviewTabs.tsx b/src/components/review/ReviewTabs.tsx new file mode 100644 index 00000000..17c2620e --- /dev/null +++ b/src/components/review/ReviewTabs.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { Text } from "@/components/common/Text"; +import { APP_ROUTES } from "@/lib/constants/appRoutes"; +import { cn } from "@/lib/utils/cn"; + +const TABS = [ + { href: APP_ROUTES.REVIEWS.WRITABLE, label: "작성 가능한 리뷰" }, + { href: APP_ROUTES.REVIEWS.ME, label: "내가 작성한 리뷰" }, +] as const; + +// 2026.07.27 정슬기 - [추가] 리뷰 관리 탭 (견적 MyEstimateTabs 패턴) +export default function ReviewTabs() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/review/ReviewWriteModal.tsx b/src/components/review/ReviewWriteModal.tsx new file mode 100644 index 00000000..0bc9f67c --- /dev/null +++ b/src/components/review/ReviewWriteModal.tsx @@ -0,0 +1,229 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; + +import Button from "@/components/common/Button/Button"; +import Textarea from "@/components/common/Input/Textarea"; +import Modal from "@/components/common/Modal/Modal"; +import { Text } from "@/components/common/Text"; +import ReviewStarRating from "@/components/review/ReviewStarRating"; +import { useCreateReview } from "@/hooks/useCreateReview"; +import { ProfileDefaultIcon } from "@/icons"; +import type { ReviewableEstimateItem } from "@/types/review"; + +const MIN_CONTENT_LENGTH = 10; + +interface ReviewWriteModalProps { + open: boolean; + item: ReviewableEstimateItem | null; + onClose: () => void; + onSuccess?: () => void; + onError?: (message: string) => void; +} + +interface ReviewWriteModalContentProps { + item: ReviewableEstimateItem; + onClose: () => void; + onSuccess?: () => void; + onError?: (message: string) => void; +} + +function ReviewWriteModalContent({ + item, + onClose, + onSuccess, + onError, +}: ReviewWriteModalContentProps) { + const [rating, setRating] = useState(0); + const [content, setContent] = useState(""); + const [contentError, setContentError] = useState(); + const [ratingError, setRatingError] = useState(); + + const createMutation = useCreateReview({ + onSuccess: () => { + onSuccess?.(); + onClose(); + }, + onError, + }); + + const displayName = item.mover.nickname?.trim() || "기사님"; + const trimmedContent = content.trim(); + const isSubmitDisabled = + createMutation.isPending || rating < 1 || trimmedContent.length < MIN_CONTENT_LENGTH; + + const handleSubmit = () => { + let hasError = false; + + if (rating < 1) { + setRatingError("별점을 선택해주세요."); + hasError = true; + } else { + setRatingError(undefined); + } + + if (trimmedContent.length < MIN_CONTENT_LENGTH) { + setContentError(`리뷰 내용은 ${MIN_CONTENT_LENGTH}자 이상 입력해주세요.`); + hasError = true; + } else { + setContentError(undefined); + } + + if (hasError) return; + + createMutation.mutate({ + estimateId: item.estimateId, + rating, + content: trimmedContent, + }); + }; + + return ( + +
+ 리뷰 작성 + {!createMutation.isPending ? : null} +
+ +
+
+
+ {item.mover.imageUrl ? ( + {`${displayName} + ) : ( + + )} +
+
+ + {displayName} 기사님 + + + {item.estimateRequest.fromAddress} + + {item.estimateRequest.toAddress} + +
+
+ +
+ + 별점을 선택해주세요 + + { + setRating(next); + setRatingError(undefined); + }} + size="lg" + label="별점" + disabled={createMutation.isPending} + /> + {ratingError ? ( + + {ratingError} + + ) : null} +
+ +
+ +