Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions src/components/common/CourseReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export interface CourseReviewCardProps {
image: string;
title: string;
duration: string;
courseType: string;
/** 이동 수단 라벨(걷기·자동차). 코스 타입(OFFICIAL·LOCAL)과 다르다. */
transport: string;
// 리뷰 목록 API의 코스 정보에는 동행·해시태그가 없어, 값이 없으면 해당
// 항목만 빼고 그린다. 백엔드에 추가 요청해 둔 상태다.
companion?: string;
Expand All @@ -44,7 +45,7 @@ function CourseReviewCard({
image,
title,
duration,
courseType,
transport,
companion,
tags = [],
profileImage,
Expand Down Expand Up @@ -84,7 +85,7 @@ function CourseReviewCard({

const metaItems = [
{ icon: calendar, label: duration },
{ icon: location, label: courseType },
{ icon: location, label: transport },
{ icon: people, label: companion },
].filter((item) => Boolean(item.label));

Expand Down
6 changes: 3 additions & 3 deletions src/components/common/ReviewActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@ function ReviewActionMenu({
action?.();
};

// 동작이 붙어 있는 항목만 내보낸다. 화면마다 할 수 있는 일이 달라서
// (코스별 후기 응답에 imageKey가 없어 그쪽에서는 수정을 못 연다),
// 눌러도 아무 일 없는 항목이 남지 않게 한다.
// 동작이 붙어 있는 항목만 내보낸다. 후기 화면 넷은 모두 수정·삭제를 다
// 붙이지만, 핸들러를 넘기지 않은 호출부에서 눌러도 아무 일 없는 항목이
// 남지 않게 한다.
const menuItems = [
{ key: 'edit', label: '수정', action: onEditClick },
{ key: 'delete', label: '삭제', action: onDeleteClick },
Expand Down
5 changes: 2 additions & 3 deletions src/components/common/ReviewEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,8 @@ function ReviewEditModal({
!isPending &&
isReviewFormValid({ rating, review: content, photoCount: photos.length });

// 사진을 손대지 않았으면 목록을 보내지 않는다. 서버가 기존 이미지를 그대로
// 두므로 헛된 삭제/삽입이 없고, 지금은 이미 붙어 있는 imageKey를 다시
// 보내면 500이 나는 서버 버그도 피해 간다(별점·내용 수정이 그래서 막혀 있었다).
// 사진을 손대지 않았으면 목록을 보내지 않는다. 명세대로 서버가 기존
// 이미지를 그대로 두므로 헛된 삭제/삽입이 없다.
const initialImageKeys = review.editableImages
.slice(0, MAX_REVIEW_PHOTOS)
.map(({ imageKey }) => imageKey);
Expand Down
22 changes: 13 additions & 9 deletions src/hooks/useCourses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ export function useCourseDetail(courseId: number | null) {
* 여러 코스의 상세를 한꺼번에 읽는다.
*
* 후기 목록 응답의 course에는 해시태그와 동행이 없어서, 후기 카드에 그리려면
* 코스별로 상세를 더 받아야 한다. useCourseDetail과 캐시 키가 같아 카드를
* 눌러 이동할 때의 타입 조회도 이 캐시를 그대로 쓴다.
* 코스별로 상세를 더 받아야 한다. useCourseDetail과 캐시 키가 같아 이 캐시를
* 함께 쓴다.
*
* 백엔드가 ReviewCourse에 tags·companionType·courseType을 넣어주면 이 조회
* 전체를 걷어낼 수 있다.
* 백엔드가 ReviewCourse에 tags·companionType을 넣어주면 이 조회 전체를
* 걷어낼 수 있다.
*/
export function useCourseDetails(courseIds: readonly number[]) {
return useQueries({
Expand All @@ -124,12 +124,15 @@ export function useCourseDetails(courseIds: readonly number[]) {
}

/**
* 코스 상세로 이동한다.
* 코스 타입을 모르는 목록에서 코스 상세로 이동한다.
*
* 후기 응답에는 코스 타입이 없어서 어느 상세 라우트로 보낼지 알 수 없다.
* 상세를 먼저 받아 courseType을 보고 경로를 정한다. 잘못된 라우트로 보내면
* 상세 페이지가 NotFound를 띄우기 때문에 추측으로 보낼 수 없다.
* 백엔드가 ReviewCourse에 courseType을 추가하면 이 조회를 걷어낼 수 있다.
* 여기도(OFFICIAL)와 동네(LOCAL) 상세 라우트가 나뉘어 있고, 각 페이지는 자기
* 타입이 아닌 코스를 받으면 NotFound를 띄운다. 그래서 타입을 추측해 보낼 수
* 없고, 상세를 먼저 받아 courseType을 보고 경로를 정한다.
*
* 응답에 courseType이 들어 있는 목록(후기 등)은 이 훅을 쓰지 말고
* buildCourseDetailPath로 바로 이동하면 된다. 지금은 찜 목록
* (LikedResponse에 courseType이 없다)이 이 훅을 쓴다.
*/
export function useNavigateToCourseDetail() {
const navigate = useNavigate();
Expand Down Expand Up @@ -161,3 +164,4 @@ export function useNavigateToCourseDetail() {

return { goToCourseDetail, isResolvingCourse };
}

10 changes: 5 additions & 5 deletions src/hooks/useReviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,13 @@ export function useCreateCourseReview() {
}

/**
* 아직 쓰이는 화면이 없다.
* 후기 수정. useReviewEdit이 감싸서 쓴다.
*
* 조회 응답이 imageKey를 안 내려줘서 "유지할 사진"을 지목할 수 없고, 그래서
* 사진을 부분 수정하는 화면을 만들 수 없다. 백엔드에 추가 요청해 둔 상태라
* 그때 바로 붙일있게 남겨 둔다.
* 두 목록 응답(GET /reviews, GET /courses/{id}/reviews)이 모두 imageKey를
* 내려주므로, 후기가 보이는 네 화면(홈·최근 후기·코스 상세·후기 전체보기)
* 전부에서 수정에 들어갈있다.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export function useUpdateReview() {
function useUpdateReview() {
const queryClient = useQueryClient();

return useMutation<UpdateReviewResponse, Error, UpdateReviewParams>({
Expand Down
6 changes: 6 additions & 0 deletions src/pages/course-reviews/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ReviewCardSkeleton,
ReviewDeleteDialog,
ReviewDetailModal,
ReviewEditModal,
} from '../../components/common';
import { ResponsivePageShell } from '../../components/layout';
import { useGlobalScale } from '../../hooks/useGlobalScale';
Expand All @@ -19,6 +20,7 @@ import {
useMyReviewIds,
useReviewDelete,
useReviewDetailModal,
useReviewEdit,
} from '../../hooks/useReviews';
import { getGutter } from '../../utils/responsiveLayout';
import { ReviewButton } from '../detail/components';
Expand Down Expand Up @@ -96,6 +98,7 @@ function CourseReviewsPage() {
const isLoading = validCourseId !== undefined && isPending;
const myReviewIds = useMyReviewIds();
const { requestDelete, dialogProps } = useReviewDelete();
const { requestEdit, editorProps } = useReviewEdit();

const reviews = mapCourseReviewPreviews(
getCourseReviewsFromPages(data?.pages),
Expand Down Expand Up @@ -209,6 +212,7 @@ function CourseReviewsPage() {
rating={review.rating}
isMine={review.isMine}
onDeleteClick={() => requestDelete(review.id)}
onEditClick={() => requestEdit(review)}
onClick={() => navigate(courseDetailPath)}
onLongPress={() => openReview(review.id)}
variant="course-review-list"
Expand All @@ -233,6 +237,8 @@ function CourseReviewsPage() {
onGoToCourse={() => navigate(courseDetailPath)}
/>

<ReviewEditModal key={editorProps.review?.id} {...editorProps} />

<ReviewDeleteDialog {...dialogProps} />

{!reviewButton || typeof document === 'undefined'
Expand Down
6 changes: 6 additions & 0 deletions src/pages/detail/components/CourseDetailLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useShareToast } from '../hooks/useShareToast';
import {
ReviewDeleteDialog,
ReviewDetailModal,
ReviewEditModal,
} from '../../../components/common';
import { useGlobalScale } from '../../../hooks/useGlobalScale';
import { useLoginModal } from '../../../hooks/useLoginModal';
Expand All @@ -34,6 +35,7 @@ import {
useMyReviewIds,
useReviewDelete,
useReviewDetailModal,
useReviewEdit,
} from '../../../hooks/useReviews';
import { useAuthStore } from '../../../store/auth.store';
import { mapCourseReviewPreviews } from '../mappers/courseReviewMapper';
Expand Down Expand Up @@ -120,6 +122,7 @@ function CourseDetailLayoutContent({
const myReviewIds = useMyReviewIds();
const reviews = mapCourseReviewPreviews(courseReviews?.items, myReviewIds);
const { requestDelete, dialogProps } = useReviewDelete();
const { requestEdit, editorProps } = useReviewEdit();
const { openedReview, openReview, closeReview } =
useReviewDetailModal(reviews);

Expand Down Expand Up @@ -333,6 +336,7 @@ function CourseDetailLayoutContent({
courseTitle={course.title}
onActionClick={handleNavigateCourseReviews}
onReviewDelete={requestDelete}
onReviewEdit={requestEdit}
onReviewLongPress={openReview}
/>
</div>
Expand All @@ -353,6 +357,8 @@ function CourseDetailLayoutContent({
onClose={closeReview}
/>

<ReviewEditModal key={editorProps.review?.id} {...editorProps} />

<ReviewDeleteDialog {...dialogProps} />
</ResponsivePageShell>
);
Expand Down
5 changes: 5 additions & 0 deletions src/pages/detail/components/DetailReviewSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface DetailReviewSectionProps {
readonly className?: string;
readonly onActionClick?: () => void;
readonly onReviewDelete?: (reviewId: number) => void;
readonly onReviewEdit?: (review: CourseReview) => void;
readonly onReviewLongPress?: (reviewId: number) => void;
}

Expand All @@ -36,6 +37,7 @@ export function DetailReviewSection({
className = '',
onActionClick,
onReviewDelete,
onReviewEdit,
onReviewLongPress,
}: DetailReviewSectionProps) {
const scale = useGlobalScale();
Expand Down Expand Up @@ -136,6 +138,9 @@ export function DetailReviewSection({
onDeleteClick={
onReviewDelete ? () => onReviewDelete(review.id) : undefined
}
onEditClick={
onReviewEdit ? () => onReviewEdit(review) : undefined
}
onLongPress={
onReviewLongPress
? () => onReviewLongPress(review.id)
Expand Down
2 changes: 2 additions & 0 deletions src/pages/detail/constants/courseDetailMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const courseDetailMockData: CourseDetailDto = {
{
id: 1,
images: ['', '', ''],
editableImages: [],
profileImage: courseImage,
nickname: '민지',
meta: '20대 여',
Expand All @@ -75,6 +76,7 @@ export const courseDetailMockData: CourseDetailDto = {
{
id: 2,
images: ['', ''],
editableImages: [],
profileImage: courseImage,
nickname: '느린여행자',
meta: '30대 남',
Expand Down
4 changes: 3 additions & 1 deletion src/pages/detail/mappers/courseReviewMapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toReviewerMetaLabel } from '../../../utils/courseEnumLabels.ts';
import { toEditableImages, toImageUrls } from '../../../utils/reviewCard.ts';

import type { CourseReviewPreview } from '../../../types/review.type';
import type { CourseReview } from '../types/courseDetail';
Expand All @@ -16,7 +17,8 @@ function mapCourseReviewPreview(
): CourseReview {
return {
id: preview.reviewId,
images: preview.imageUrls ?? [],
images: toImageUrls(preview.images),
editableImages: toEditableImages(preview.images),
profileImage: preview.author?.profileImageUrl ?? '',
nickname: preview.author?.nickname ?? '',
meta: toReviewerMetaLabel(preview.author?.ageGroup, preview.author?.gender),
Expand Down
2 changes: 2 additions & 0 deletions src/pages/detail/types/courseDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type CourseInfoBadgeTuple = readonly [
export interface CourseReview {
readonly id: number;
readonly images: string[];
/** 수정 화면이 "유지할 사진"을 지목하는 데 쓴다. */
readonly editableImages: { imageKey: string; imageUrl: string }[];
readonly profileImage: string;
readonly nickname: string;
readonly meta: string;
Expand Down
11 changes: 5 additions & 6 deletions src/pages/home/components/ReviewSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from '../../../components/common';
import { useNavigate } from 'react-router-dom';

import { useNavigateToCourseDetail } from '../../../hooks/useCourses';
import { useGlobalScale } from '../../../hooks/useGlobalScale';
import {
useMyReviewIds,
Expand All @@ -19,6 +18,7 @@ import {
useReviewDetailModal,
useReviewEdit,
} from '../../../hooks/useReviews';
import { buildCourseDetailPath } from '../../../utils/routes';
import { toReviewCardProps } from '../../../utils/reviewCard';

// Figma 390 디자인 기준 리터럴 px
Expand All @@ -43,9 +43,10 @@ function ReviewSection() {
const { requestEdit, editorProps } = useReviewEdit();
const { openedReview, openReview, closeReview } =
useReviewDetailModal(reviews);
const { goToCourseDetail } = useNavigateToCourseDetail();
const navigate = useNavigate();
const scale = useGlobalScale();
const goToCourseDetail = (review: { courseType: string; courseId: number }) =>
navigate(buildCourseDetailPath(review.courseType, review.courseId));

const scrollRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
Expand Down Expand Up @@ -160,7 +161,7 @@ function ReviewSection() {
isMine={review.isMine}
onDeleteClick={() => requestDelete(review.id)}
onEditClick={() => requestEdit(review)}
onClick={() => void goToCourseDetail(review.courseId)}
onClick={() => goToCourseDetail(review)}
onLongPress={() => openReview(review.id)}
/>
</div>
Expand Down Expand Up @@ -205,9 +206,7 @@ function ReviewSection() {
review={openedReview}
courseTitle={openedReview?.courseTitle}
onClose={closeReview}
onGoToCourse={
openedReview && (() => void goToCourseDetail(openedReview.courseId))
}
onGoToCourse={openedReview && (() => goToCourseDetail(openedReview))}
/>

<ReviewEditModal key={editorProps.review?.id} {...editorProps} />
Expand Down
27 changes: 13 additions & 14 deletions src/pages/recent-review-courses/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';

import {
CourseReviewCard,
Expand All @@ -9,10 +10,7 @@ import {
} from '../../components/common';
import { useAuth } from '../../hooks/useAuth';
import { useCourseLikeToggle } from '../../hooks/useCourseLikeToggle';
import {
useCourseDetails,
useNavigateToCourseDetail,
} from '../../hooks/useCourses';
import { useCourseDetails } from '../../hooks/useCourses';
import { useGlobalScale } from '../../hooks/useGlobalScale';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import {
Expand All @@ -26,6 +24,7 @@ import {
import { toCompanionLabel } from '../../utils/courseEnumLabels';
import { toContentTagIds } from '../../utils/contentTags';
import { toReviewCourseCardProps } from '../../utils/reviewCard';
import { buildCourseDetailPath } from '../../utils/routes';

const PAGE_PADDING_X = 24;
const PAGE_PADDING_TOP = 12;
Expand Down Expand Up @@ -55,7 +54,9 @@ function RecentReviewCoursesPage() {
const myReviewIds = useMyReviewIds();
const { requestDelete, dialogProps } = useReviewDelete();
const { requestEdit, editorProps } = useReviewEdit();
const { goToCourseDetail } = useNavigateToCourseDetail();
const navigate = useNavigate();
const goToCourseDetail = (review: { courseType: string; courseId: number }) =>
navigate(buildCourseDetailPath(review.courseType, review.courseId));

const reviews = getReviewsFromPages(data?.pages).map((review) =>
toReviewCourseCardProps(review, myReviewIds)
Expand All @@ -71,10 +72,10 @@ function RecentReviewCoursesPage() {
courseDetails.flatMap(({ data }) => (data ? [[data.courseId, data]] : []))
);

// 후기 목록의 course.isLiked는 서버가 아직 임시 사용자 기준으로 계산해서
// 비로그인에도 남의 좋아요가 켜져 온다. 로그인하지 않았으면 좋아요가 있을
// 수 없으므로 무조건 끈다. 로그인 상태에서는 사용자 기준으로 맞게 오는
// 코스 상세 값을 우선 쓴다. 백엔드가 고치면 review.liked만 남기면 된다.
// 후기 목록의 course.isLiked는 한때 임시 사용자 기준으로 계산돼 비로그인에도
// 남의 좋아요가 켜져 왔다. 지금은 비로그인 응답이 false로 오는 것까지
// 확인했지만, 해시태그 때문에 어차피 읽는 코스 상세가 사용자 기준으로 맞는
// 값이라 그쪽을 우선 쓴다. 추가 비용이 없어 방어를 남겨 둔다.
const likedByCourse = (review: { courseId: number; liked: boolean }) => {
if (!isAuthenticated) return false;

Expand Down Expand Up @@ -163,7 +164,7 @@ function RecentReviewCoursesPage() {
image={review.image}
title={review.title}
duration={review.duration}
courseType={review.courseType}
transport={review.transport}
companion={
course ? toCompanionLabel(course.companionType) : undefined
}
Expand All @@ -183,7 +184,7 @@ function RecentReviewCoursesPage() {
}
onDeleteClick={() => requestDelete(review.id)}
onEditClick={() => requestEdit(review)}
onClick={() => void goToCourseDetail(review.courseId)}
onClick={() => goToCourseDetail(review)}
onLongPress={() => openReview(review.id)}
/>
);
Expand All @@ -201,9 +202,7 @@ function RecentReviewCoursesPage() {
review={openedReview}
courseTitle={openedReview?.title}
onClose={closeReview}
onGoToCourse={
openedReview && (() => void goToCourseDetail(openedReview.courseId))
}
onGoToCourse={openedReview && (() => goToCourseDetail(openedReview))}
/>

<ReviewEditModal key={editorProps.review?.id} {...editorProps} />
Expand Down
Loading