Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import dayjs from 'dayjs';
import dynamic from 'next/dynamic';
import { useMemo, useState } from 'react';
import { axiosInstance } from '@/api/client/axios';
import type {
GroupStudyBasicInfoResponseDto,
GroupStudyDetailInfoResponseDto,
} from '@/api/openapi';
import GroupStudyReviewModal from '@/components/common/modals/group-study-review-modal';
import Pagination from '@/components/common/ui/pagination';
import { useAuthReady } from '@/hooks/common/use-auth';
Expand All @@ -32,6 +28,8 @@ interface CompletedStudyReviewPageProps {
basePath: string;
studyType: 'GROUP_STUDY' | 'PREMIUM_STUDY' | 'ONE_ON_ONE_STUDY';
studyTypeName: string;
hideTabNav?: boolean;
hideEmptyMessage?: boolean;
}

interface StudyRoleSectionProps {
Expand All @@ -51,10 +49,8 @@ function StudyRoleSection({
}: StudyRoleSectionProps) {
return (
<section className="flex flex-col gap-200">
<div className="flex flex-col gap-50">
<div className="flex items-center gap-100">
<h2 className="font-designer-20b text-text-default">{title}</h2>
</div>
<div className="flex items-center gap-100">
<h2 className="font-designer-20b text-text-default">{title}</h2>
</div>

{studies.length > 0 ? (
Expand All @@ -81,6 +77,8 @@ export default function CompletedStudyReviewPage({
basePath,
studyType,
studyTypeName,
hideTabNav = false,
hideEmptyMessage = false,
}: CompletedStudyReviewPageProps) {
const [page, setPage] = useState(1);
const [reviewStudy, setReviewStudy] = useState<MemberStudyItem | null>(null);
Expand Down Expand Up @@ -162,17 +160,30 @@ export default function CompletedStudyReviewPage({
};

const activeReviewStudyId = reviewStudy?.studyId;

const handleSubmitSuccess = () => {
if (activeReviewStudyId === undefined) return;
setSubmittedStudyIds((prev) =>
prev.includes(activeReviewStudyId)
? prev
: [...prev, activeReviewStudyId],
);
setTimeout(() => setShowCompletionModal(true), 300);
};

const emptyParticipatedMessage = `아직 참여한 ${studyTypeName}가 없습니다.`;
const emptyLedMessage = `아직 개설한 ${studyTypeName}가 없습니다.`;

return (
<div className="flex flex-col gap-400">
<StudyReviewTabNav />
{!hideTabNav && <StudyReviewTabNav />}

{completedStudies.length === 0 ? (
<div className="font-designer-14r text-text-subtle flex h-200 items-center justify-center text-center">
{emptyParticipatedMessage}
</div>
!hideEmptyMessage && (
<div className="font-designer-14r text-text-subtle flex h-200 items-center justify-center text-center">
{emptyParticipatedMessage}
</div>
)
) : (
<>
<StudyRoleSection
Expand All @@ -184,7 +195,7 @@ export default function CompletedStudyReviewPage({
/>

<StudyRoleSection
title="종료된 스터디"
title="운영한 스터디"
studies={leaderStudies}
basePath={basePath}
emptyMessage={emptyLedMessage}
Expand All @@ -200,56 +211,34 @@ export default function CompletedStudyReviewPage({

{activeReviewStudyId !== undefined &&
reviewStudy &&
studyType !== 'ONE_ON_ONE_STUDY' && (
<GroupStudyReviewModal
(studyType === 'ONE_ON_ONE_STUDY' ? (
<StudyReviewModal
open={!!reviewStudy}
onOpenChange={(open) => {
if (!open) {
setReviewStudy(null);
}
}}
groupStudyId={activeReviewStudyId}
detailInfo={
{ title: reviewStudy.title } as GroupStudyDetailInfoResponseDto
}
basicInfo={
{
startDate: dayjs(reviewStudy.startTime).format('YYYY.MM.DD'),
endDate: dayjs(reviewStudy.endTime).format('YYYY.MM.DD'),
} as GroupStudyBasicInfoResponseDto
}
onSubmitSuccess={() => {
setSubmittedStudyIds((prev) =>
prev.includes(activeReviewStudyId)
? prev
: [...prev, activeReviewStudyId],
);
setTimeout(() => setShowCompletionModal(true), 300);
}}
targetStudySpaceId={activeReviewStudyId}
onSubmitSuccess={handleSubmitSuccess}
/>
)}

{activeReviewStudyId !== undefined &&
reviewStudy &&
studyType === 'ONE_ON_ONE_STUDY' && (
<StudyReviewModal
) : (
<GroupStudyReviewModal
open={!!reviewStudy}
onOpenChange={(open) => {
if (!open) {
setReviewStudy(null);
}
}}
targetStudySpaceId={activeReviewStudyId}
onSubmitSuccess={() => {
setSubmittedStudyIds((prev) =>
prev.includes(activeReviewStudyId)
? prev
: [...prev, activeReviewStudyId],
);
setTimeout(() => setShowCompletionModal(true), 300);
groupStudyId={activeReviewStudyId}
detailInfo={{ title: reviewStudy.title }}
basicInfo={{
startDate: dayjs(reviewStudy.startTime).format('YYYY.MM.DD'),
endDate: dayjs(reviewStudy.endTime).format('YYYY.MM.DD'),
}}
onSubmitSuccess={handleSubmitSuccess}
/>
)}
))}

<StudyCompletionModal
open={showCompletionModal}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ export default function EvaluationSection({
studyTypeName,
}: EvaluationSectionProps) {
const emptyMessage = studyTypeName
? `아직 받은 ${studyTypeName} 평가가 없습니다`
: '아직 받은 평가가 없습니다';
? `아직 받은 ${studyTypeName} 평가가 없습니다.`
: '아직 받은 평가가 없습니다.';
const goodItems = statistics?.goodItems ?? [];
const disappointedItems = statistics?.disappointedItems ?? [];
const goodTotalCount = goodItems.reduce(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default function GroupStudyReviewDetailPage() {
aria-label="뒤로가기"
>
<ChevronLeft className="text-text-subtle" size={20} />
<span className="font-designer-13r text-text-subtle">
<span className="text-text-subtle font-designer-14m flex cursor-pointer items-center gap-50 hover:text-text-default">
그룹 스터디 목록
</span>
</button>
Expand Down Expand Up @@ -100,7 +100,7 @@ export default function GroupStudyReviewDetailPage() {
))}
</ul>
) : (
<div className="font-designer-14r text-text-subtle flex h-200 items-center justify-center text-center">
<div className="flex flex-col font-designer-14r text-text-subtle h-200 items-center justify-center text-center">
아직 받은 {studyTypeName} 후기가 없습니다.
</div>
)}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cn } from '@/components/common/ui/(shadcn)/lib/utils';
import Avatar from '@/components/common/ui/avatar';
import type { MyReviewItem } from '@/types/api/review.types';
import { formatDateTimeDot } from '@/utils/time';
import { useExpandableContent } from '../../../_components/use-expandable-content';
import { useExpandableContent } from '../../_components/use-expandable-content';

export default function OneToOneReviewCard({
review,
Expand Down
26 changes: 26 additions & 0 deletions src/app/(service)/(my)/my-study-review/one-to-one/_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { GroupStudyReviewStatistics } from '@/types/api/group-study-review.types';

interface ReviewStatisticsProps {
id: number;
content: string;
count: number;
}

export function buildEvaluationStatistics(
positiveKeywords: ReviewStatisticsProps[],
negativeKeywords: ReviewStatisticsProps[],
): GroupStudyReviewStatistics {
const filterAndMap = (keywords: ReviewStatisticsProps[]) =>
keywords
.filter((keyword) => keyword.count > 0)
.map((keyword) => ({
id: keyword.id,
label: keyword.content,
count: keyword.count,
}));

return {
goodItems: filterAndMap(positiveKeywords),
disappointedItems: filterAndMap(negativeKeywords),
};
}
78 changes: 73 additions & 5 deletions src/app/(service)/(my)/my-study-review/one-to-one/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,81 @@
'use client';

import {
useMyNegativeKeywordsQuery,
useMyReviewsInfinityQuery,
useUserPositiveKeywordsQuery,
} from '@/hooks/queries/use-review-query';

import { buildEvaluationStatistics } from './_utils';
import CompletedStudyReviewPage from '../_components/completed-study-review-page';
import StudyReviewTabNav from '../_components/study-review-tab-nav';
import OneToOneReviewCard from './_components/one-to-one-review-card';
import EvaluationSection from '../group/[groupStudyId]/_components/evaluation-section';

export default function OneToOneReviewPage() {
const { data: positiveData } = useUserPositiveKeywordsQuery({});
const { data: negativeData } = useMyNegativeKeywordsQuery({});
const {
data: reviewData,
fetchNextPage,
hasNextPage,
} = useMyReviewsInfinityQuery();
Comment on lines +16 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

hook_file="$(fd 'use-review-query\.(ts|tsx)$' src | head -n1)"
echo "Hook file: $hook_file"

echo
echo "== Hook definitions =="
rg -n -A20 -B5 'useUserPositiveKeywordsQuery|useMyNegativeKeywordsQuery|useMyReviewsInfinityQuery' "$hook_file"

echo
echo "== Review filter / endpoint references =="
rg -n -A4 -B4 'ONE_ON_ONE_STUDY|studyType|positiveKeywords|negativeKeywords|myReviews' src \
  -g '!**/*.test.*'

Repository: code-zero-to-one/study-platform-client

Length of output: 50397


🏁 Script executed:

# API 구현체 찾기
find src/api -name "*.ts" -type f | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 879


🏁 Script executed:

# use-review-query.ts 전체 내용 확인
wc -l src/hooks/queries/use-review-query.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 123


🏁 Script executed:

# 페이지 파일 확인
cat -n src/app/\(service\)/\(my\)/my-study-review/one-to-one/page.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 3193


🏁 Script executed:

# API 함수 구현 찾기
rg -n "getUserPositiveKeywords|getMyNegativeKeywords|getMyReviews" src/api -A10 | head -100

Repository: code-zero-to-one/study-platform-client

Length of output: 9623


🏁 Script executed:

cat -n src/api/endpoints/review/get-review.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 3165


🏁 Script executed:

cat -n src/hooks/queries/use-review-query.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 5589


🏁 Script executed:

# API 타입 정의 확인
rg -n "UserPositiveKeywordsRequest|MyNegativeKeywordsRequest|MyReviewsRequest" src/types -A5

Repository: code-zero-to-one/study-platform-client

Length of output: 1125


세 쿼리가 모든 스터디 타입의 데이터를 함께 조회하고 있습니다.

이 페이지는 1:1 리뷰만 표시해야 하지만, 호출하는 세 쿼리(useUserPositiveKeywordsQuery, useMyNegativeKeywordsQuery, useMyReviewsInfinityQuery)는 모두 studyType 파라미터가 없습니다. API 함수, 타입 정의, 엔드포인트 모두에서 1:1 필터링이 불가능하므로, 긍정 키워드, 부정 키워드, 후기 데이터가 그룹 스터디 리뷰와 함께 섞여 조회됩니다.

CompletedStudyReviewPage 컴포넌트처럼 studyType="ONE_ON_ONE_STUDY" 필터를 세 쿼리에도 추가해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(service)/(my)/my-study-review/one-to-one/page.tsx around lines 16 -
22, The three data hooks on the page (useUserPositiveKeywordsQuery,
useMyNegativeKeywordsQuery, useMyReviewsInfinityQuery) are called without a
studyType filter so they return mixed study types; update each call to pass the
studyType prop used by CompletedStudyReviewPage (studyType: "ONE_ON_ONE_STUDY"
or studyType="ONE_ON_ONE_STUDY") so only 1:1 review data is fetched, and adjust
any call signatures if necessary to accept that parameter (ensure
useMyReviewsInfinityQuery receives the same studyType argument for pagination
behavior).


const statistics = buildEvaluationStatistics(
positiveData?.keywords ?? [],
negativeData?.keywords ?? [],
);

const reviews = reviewData?.reviews ?? [];
const totalCount = reviewData?.totalCount ?? 0;
Comment on lines +16 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

초기/실패 상태가 빈 데이터로 오인됩니다.

Line 16-30에서 새로 추가한 세 쿼리 결과를 바로 [] / 0으로 치환하고 있어서, 초기 로딩이나 요청 실패 때도 아래 섹션이 실제 빈 상태처럼 렌더링됩니다. 이 페이지는 평가 통계와 후기 수를 새로 보여주기 시작한 만큼, isPending/isError를 먼저 분기하고 empty message는 정상 응답이 비어 있을 때만 노출하는 편이 안전합니다.

As per coding guidelines 'For recoverable failures in UI: use inline error display first, Toast as secondary. Never use browser alert(). Use useToastStore for Toast display in React components and getState() outside React.'

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(service)/(my)/my-study-review/one-to-one/page.tsx around lines 16 -
30, Stop coalescing the three query results to []/0 immediately and instead
branch on their loading/error states: read the status flags returned by
useUserPositiveKeywordsQuery, useMyNegativeKeywordsQuery and
useMyReviewsInfinityQuery (e.g., isLoading/isFetching/isPending and isError) and
only derive statistics = buildEvaluationStatistics(...) and reviews/totalCount
after a successful response; when any query isError show an inline error UI (and
trigger a toast via useToastStore in component code if needed) and when any
query is loading show a loading state—only treat keywords as [] or totalCount as
0 when the queries have succeeded with an empty payload. Ensure you update
references to statistics, reviews and totalCount to depend on the successful
result flags rather than unconditionally using positiveData?.keywords ?? [] /
reviewData?.totalCount ?? 0.


return (
<CompletedStudyReviewPage
basePath="/my-study-review/one-to-one"
studyType="ONE_ON_ONE_STUDY"
studyTypeName="1:1스터디"
/>
<div className="flex flex-col gap-400">
<StudyReviewTabNav />

<EvaluationSection statistics={statistics} />

<section className="flex flex-col gap-200">
<div className="flex items-center gap-100">
<h2 className="font-designer-20b text-text-default">후기</h2>
<span className="font-designer-20b text-text-default">
{totalCount}
</span>
</div>
<span className="font-designer-14r text-text-subtle">
모든 후기는 나에게만 보여요.
</span>

{reviews.length > 0 ? (
<ul className="flex flex-col">
{reviews.map((review) => (
<OneToOneReviewCard key={review.id} review={review} />
))}
</ul>
) : (
<div className="text-text-subtle font-designer-14r flex h-200 items-center justify-center text-center">
아직까지 받은 후기가 없습니다.
</div>
)}

{hasNextPage && (
<button
type="button"
className="font-designer-14m text-text-subtle hover:bg-background-accent-gray-default rounded-50 flex w-full cursor-pointer items-center justify-center py-200"
onClick={() => fetchNextPage()}
>
더보기
</button>
)}
</section>

<CompletedStudyReviewPage
basePath="/my-study-review/one-to-one"
studyType="ONE_ON_ONE_STUDY"
studyTypeName="1:1스터디"
hideTabNav
hideEmptyMessage
/>
</div>
);
}
Loading
Loading