Skip to content

[FIX]: 리뷰 탭 전환 및 리뷰 문장 입력 UX 개선#74

Merged
Seoje1405 merged 4 commits intodevelopfrom
fix/review
Feb 18, 2026
Merged

[FIX]: 리뷰 탭 전환 및 리뷰 문장 입력 UX 개선#74
Seoje1405 merged 4 commits intodevelopfrom
fix/review

Conversation

@bk-git-hub
Copy link
Copy Markdown
Contributor

📝 개요

  • 리뷰 작성 완료 후 /reviews로 돌아왔을 때 작성 대기 탭이 선택되던 문제를 수정했습니다.
  • 리뷰 관리 탭 상태를 URL 쿼리(tab)와 동기화해 리다이렉트/새로고침 시에도 의도한 탭이 유지되도록 개선했습니다.
  • 리뷰 작성 Step2의 문장 입력 UX를 개편해, 후기 문장 받아보기 단일 흐름과 모바일 완료(Enter) 기반 직접 입력 추가를 지원합니다.

🚀 주요 변경 사항

  • 리뷰 작성 완료 후 작성 리뷰 탭 자동 노출

    • src/app/review/write/[reviewId]/_components/review-write-flow.tsx
    • 제출 성공 시 router.replace('/reviews?tab=written')로 변경
  • 리뷰 관리 탭 URL 동기화

    • src/components/reviews/review-management-content.tsx
    • ?tab=writable|written 파싱으로 활성 탭 결정
    • 탭 클릭 시 기존 쿼리 유지 + tab만 갱신
    • 동일 탭 재클릭 시 라우팅 생략, scroll: false 적용
  • Step2 문장 생성/입력 UX 개선

    • src/app/review/write/[reviewId]/_components/review-write-flow.tsx
    • 사이즈/소재 개별 생성 버튼 제거, 후기 문장 받아보기로 통합
    • 선택 조건에 따라 사이즈/소재/둘 다 생성
    • 같은 조건으로는 재생성하지 않도록 조건 키 기반 재요청 방지
  • 직접 문장 추가 UX 개편 (모바일 우선)

    • 직접 문장 추가 버튼 제거
    • 섹션별 빈 textarea 1개 상시 노출 + + 버튼으로 추가
    • 추가된 문장 행은 x 버튼으로 삭제
    • 모바일 완료/PC Enter로 문장 확정 추가 (Shift+Enter 줄바꿈, IME 조합 보호)
  • 텍스트 스타일 보정

    • 문장 입력 안내 문구를 통일하고 가독성 향상(text-base)
    • 문장 입력 textarea 기본 회색 보더 적용
    • 기타 입력란 폰트 크기 상향(text-base)

📸 스크린샷 (선택)

기능 구현 화면
사진을 여기에 드래그하세요

✅ 체크리스트

  • 빌드 테스트를 완료했나요?
  • 코드 컨벤션을 준수했나요?
  • 불필요한 console.log는 제거했나요?
  • 변경 파일 기준 lint 확인 완료

이슈 해결 여부

  • 별도 이슈 번호 연결 없이 작업했습니다.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 18, 2026

Walkthrough

리뷰 작성 흐름을 인라인 드래프트 편집 지원으로 리팩토링하여, 이전의 통합 AI 생성 로직을 필드별 독립적 가드로 변경. 리뷰 관리의 탭 네비게이션을 로컬 상태에서 URL 기반 상태로 전환.

Changes

Cohort / File(s) Summary
AI 생성 및 드래프트 편집 흐름
src/app/review/write/[reviewId]/_components/review-write-flow.tsx
AI 생성 상태 추적 refs 추가(lastGeneratedSizeAiKeyRef, lastGeneratedMaterialAiKeyRef). 통합 AI 로직을 필드별 분리 가드(sizeAiRequestKey, materialAiRequestKey) 기반으로 리팩토링. 인라인 드래프트 입력 UI 추가 및 커밋 로직 구현. 제출 성공 시 라우팅을 /reviews?tab=written으로 변경.
탭 네비게이션 상태 관리
src/components/reviews/review-management-content.tsx
useState 기반 로컬 탭 상태를 URL 쿼리 파라미터 기반 상태로 전환. useRouter, usePathname, useSearchParams를 활용한 URL 드리븐 상태 관리 구현. 탭 변경 시 router.replace로 URL 갱신.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

🐛 BUG

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 PR의 주요 변경 사항들(리뷰 탭 전환 및 문장 입력 UX 개선)을 명확하고 간결하게 요약하고 있습니다.
Description check ✅ Passed 설명은 변경 사항의 구체적인 맥락과 세부 사항들을 상세히 제시하고 있으며, 변경된 파일들과의 연관성이 명확합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/app/review/write/[reviewId]/_components/review-write-flow.tsx (1)

900-937: 삭제 가능한 리스트에 인덱스 기반 key 사용

key={size-review-${index}} / key={material-review-${index}} — 중간 항목 삭제 시 React가 남은 DOM 노드를 재사용하면서 textarea의 커서 위치·포커스 상태가 어긋날 수 있습니다. 내용 자체를 key로 쓰거나, 생성 시 고유 ID를 부여하는 방식이 안전합니다.

♻️ 제안: 안정적인 key 사용

sizeReviewItems / materialReviewItemsstring[] 대신 { id: string; value: string }[] 구조로 관리하고, 항목 추가 시 crypto.randomUUID() 또는 Date.now() 기반 ID를 할당하세요.

-const [sizeReviewItems, setSizeReviewItems] = useState<string[]>([]);
+const [sizeReviewItems, setSizeReviewItems] = useState<{ id: string; value: string }[]>([]);

이렇게 하면 key={item.id}로 안정적인 키를 사용할 수 있습니다.

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

In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx around
lines 900 - 937, The list uses index-based keys (key={`size-review-${index}`})
which causes DOM reuse issues when items are removed; refactor sizeReviewItems
and materialReviewItems from string[] to {id: string; value: string}[] (generate
id via crypto.randomUUID() or Date.now()) and use item.id for the key
(key={item.id}); update all places that read/write the string to use item.value
(render value={item.value}, setSizeReviewItems to map/update by id, and filter
removals by id), and adjust sizeTextareaRefs handling to track refs by index or
id consistently alongside autoResizeTextarea so textarea refs remain stable when
items are reordered/removed.
src/components/reviews/review-management-content.tsx (1)

35-38: nextSearch 폴백은 도달 불가능한 코드

nextParams.set('tab', tab) 호출 이후 nextSearch는 항상 "tab=..." 문자열이므로 truthy입니다. nextSearch ? ... : pathname 삼항의 pathname 브랜치는 절대 실행되지 않습니다.

♻️ 정리 제안
-   const nextSearch = nextParams.toString();
-   const nextHref = nextSearch ? `${pathname}?${nextSearch}` : pathname;
+   const nextHref = `${pathname}?${nextParams.toString()}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/reviews/review-management-content.tsx` around lines 35 - 38,
The ternary fallback is unreachable because after calling nextParams.set('tab',
tab) nextSearch will always be truthy; simplify nextHref construction by
removing the conditional and always using the serialized params (use nextParams
or nextSearch) to build the href, e.g., compute nextSearch =
nextParams.toString() and set nextHref = `${pathname}?${nextSearch}` (or handle
the empty-case only if you intentionally allow empty params), referencing
nextParams, nextSearch, nextHref, searchParams, pathname, and tab to locate and
update the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx:
- Around line 434-446: commitSizeDraftReview and commitMaterialDraftReview clear
React state but don't trigger DOM input events, so the textarea height remains
from previous content; attach refs (sizeDraftTextareaRef and
materialDraftTextareaRef) to the corresponding draft textareas and, after
calling setSizeDraftReview('') / setMaterialDraftReview(''), explicitly reset
the element's style height (or call autoResizeTextarea(textareaRef.current)) to
collapse it to the empty height—update commitSizeDraftReview and
commitMaterialDraftReview to call this ref-based resize logic and ensure the
textarea elements have ref={sizeDraftTextareaRef} /
ref={materialDraftTextareaRef}.
- Around line 408-428: The current early returns inside the startTransition
block stop further work when one AI generation fails, so change the failure
handling in the generateSizeAiReviewAction and generateMaterialAiReviewAction
blocks to not return from the enclosing callback: on failure call
setErrorMessage (with the existing message fallback), skip setting the
corresponding review items and lastGenerated... ref, but continue to attempt the
other generation; ensure both generateSizeAiReviewAction and
generateMaterialAiReviewAction are invoked independently and their results
handled separately so one failure doesn't prevent the other from running.

In `@src/components/reviews/review-management-content.tsx`:
- Around line 30-33: In handleTabChange, the guard currently compares tabParam
to tab which causes adding ?tab=... when tabParam is null; change the comparison
to use the normalized activeTab instead (i.e., return early if activeTab ===
tab) so clicks on the already-active UI tab don't modify the URL, and ensure the
subsequent navigation/update logic only runs when the activeTab differs from the
target tab.

---

Nitpick comments:
In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx:
- Around line 900-937: The list uses index-based keys
(key={`size-review-${index}`}) which causes DOM reuse issues when items are
removed; refactor sizeReviewItems and materialReviewItems from string[] to {id:
string; value: string}[] (generate id via crypto.randomUUID() or Date.now()) and
use item.id for the key (key={item.id}); update all places that read/write the
string to use item.value (render value={item.value}, setSizeReviewItems to
map/update by id, and filter removals by id), and adjust sizeTextareaRefs
handling to track refs by index or id consistently alongside autoResizeTextarea
so textarea refs remain stable when items are reordered/removed.

In `@src/components/reviews/review-management-content.tsx`:
- Around line 35-38: The ternary fallback is unreachable because after calling
nextParams.set('tab', tab) nextSearch will always be truthy; simplify nextHref
construction by removing the conditional and always using the serialized params
(use nextParams or nextSearch) to build the href, e.g., compute nextSearch =
nextParams.toString() and set nextHref = `${pathname}?${nextSearch}` (or handle
the empty-case only if you intentionally allow empty params), referencing
nextParams, nextSearch, nextHref, searchParams, pathname, and tab to locate and
update the code.

Comment on lines +408 to 428
if (shouldGenerateSizeAiReview) {
const sizeResult = await generateSizeAiReviewAction(reviewId);
if (!sizeResult.success || !sizeResult.data) {
setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.');
return;
}
setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
}
setSizeReviewItems(result.data.aiGeneratedReviews ?? []);
setSuccessMessage('사이즈 문장을 불러왔습니다.');
});
};

const handleGenerateMaterialAiReview = () => {
setErrorMessage('');
setSuccessMessage('');
startTransition(async () => {
const result = await generateMaterialAiReviewAction(reviewId);
if (!result.success || !result.data) {
setErrorMessage(result.message || '소재 AI 생성에 실패했습니다.');
return;
if (shouldGenerateMaterialAiReview) {
const materialResult = await generateMaterialAiReviewAction(reviewId);
if (!materialResult.success || !materialResult.data) {
setErrorMessage(
materialResult.message || '소재 AI 생성에 실패했습니다.',
);
return;
}
setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

사이즈 AI 생성 실패 시 소재 AI 생성이 묵묵히 스킵됨

Line 412의 returnstartTransition 콜백 전체를 종료시킵니다. shouldGenerateSizeAiReview && shouldGenerateMaterialAiReview 케이스에서 사이즈 호출이 실패하면 소재 AI 요청은 아예 시도되지 않고, 사용자는 두 번 다시 버튼을 눌러야 합니다. PR 목표("size, material, or both 조건 기반 생성")를 감안하면 두 요청은 독립적으로 처리되어야 합니다.

🔧 제안 수정: 독립 실패 처리
   startTransition(async () => {
+    let hasError = false;
+
     if (shouldGenerateSizeAiReview) {
       const sizeResult = await generateSizeAiReviewAction(reviewId);
       if (!sizeResult.success || !sizeResult.data) {
         setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.');
-        return;
+        hasError = true;
+      } else {
+        setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
+        lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
       }
-      setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
-      lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
     }

     if (shouldGenerateMaterialAiReview) {
       const materialResult = await generateMaterialAiReviewAction(reviewId);
       if (!materialResult.success || !materialResult.data) {
         setErrorMessage(materialResult.message || '소재 AI 생성에 실패했습니다.');
-        return;
+        hasError = true;
+      } else {
+        setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
+        lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
       }
-      setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
-      lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
     }

-    setSuccessMessage('AI 문장을 불러왔습니다.');
+    if (!hasError) {
+      setSuccessMessage('AI 문장을 불러왔습니다.');
+    }
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (shouldGenerateSizeAiReview) {
const sizeResult = await generateSizeAiReviewAction(reviewId);
if (!sizeResult.success || !sizeResult.data) {
setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.');
return;
}
setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
}
setSizeReviewItems(result.data.aiGeneratedReviews ?? []);
setSuccessMessage('사이즈 문장을 불러왔습니다.');
});
};
const handleGenerateMaterialAiReview = () => {
setErrorMessage('');
setSuccessMessage('');
startTransition(async () => {
const result = await generateMaterialAiReviewAction(reviewId);
if (!result.success || !result.data) {
setErrorMessage(result.message || '소재 AI 생성에 실패했습니다.');
return;
if (shouldGenerateMaterialAiReview) {
const materialResult = await generateMaterialAiReviewAction(reviewId);
if (!materialResult.success || !materialResult.data) {
setErrorMessage(
materialResult.message || '소재 AI 생성에 실패했습니다.',
);
return;
}
setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
}
if (shouldGenerateSizeAiReview) {
let hasError = false;
const sizeResult = await generateSizeAiReviewAction(reviewId);
if (!sizeResult.success || !sizeResult.data) {
setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.');
hasError = true;
} else {
setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
}
}
if (shouldGenerateMaterialAiReview) {
const materialResult = await generateMaterialAiReviewAction(reviewId);
if (!materialResult.success || !materialResult.data) {
setErrorMessage(
materialResult.message || '소재 AI 생성에 실패했습니다.',
);
hasError = true;
} else {
setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
}
}
if (!hasError) {
setSuccessMessage('AI 문장을 불러왔습니다.');
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx around
lines 408 - 428, The current early returns inside the startTransition block stop
further work when one AI generation fails, so change the failure handling in the
generateSizeAiReviewAction and generateMaterialAiReviewAction blocks to not
return from the enclosing callback: on failure call setErrorMessage (with the
existing message fallback), skip setting the corresponding review items and
lastGenerated... ref, but continue to attempt the other generation; ensure both
generateSizeAiReviewAction and generateMaterialAiReviewAction are invoked
independently and their results handled separately so one failure doesn't
prevent the other from running.

Comment on lines +434 to 446
const commitSizeDraftReview = () => {
const nextValue = sizeDraftReview.trim();
if (!nextValue) return;
setSizeReviewItems((prev) => [...prev, nextValue]);
setSizeDraftReview('');
};

const handleAddMaterialReviewItem = () => {
setMaterialReviewItems((prev) => {
setPendingMaterialFocusIndex(prev.length);
return [...prev, ''];
});
const commitMaterialDraftReview = () => {
const nextValue = materialDraftReview.trim();
if (!nextValue) return;
setMaterialReviewItems((prev) => [...prev, nextValue]);
setMaterialDraftReview('');
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

드래프트 커밋 후 textarea 높이가 리셋되지 않음

setSizeDraftReview('') / setMaterialDraftReview('')는 React 상태를 비우지만 DOM onInput 이벤트를 발생시키지 않으므로, autoResizeTextarea가 호출되지 않아 빈 textarea가 이전 입력 높이를 유지합니다. 커밋 후 텍스트에어리어에 ref를 달고 명시적으로 높이를 초기화해야 합니다.

🔧 제안 수정
+const sizeDraftTextareaRef = useRef<HTMLTextAreaElement | null>(null);
+const materialDraftTextareaRef = useRef<HTMLTextAreaElement | null>(null);

 const commitSizeDraftReview = () => {
   const nextValue = sizeDraftReview.trim();
   if (!nextValue) return;
   setSizeReviewItems((prev) => [...prev, nextValue]);
   setSizeDraftReview('');
+  autoResizeTextarea(sizeDraftTextareaRef.current);
 };

 const commitMaterialDraftReview = () => {
   const nextValue = materialDraftReview.trim();
   if (!nextValue) return;
   setMaterialReviewItems((prev) => [...prev, nextValue]);
   setMaterialDraftReview('');
+  autoResizeTextarea(materialDraftTextareaRef.current);
 };

그리고 각 드래프트 textarea에 ref={sizeDraftTextareaRef} / ref={materialDraftTextareaRef} 추가.

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

In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx around
lines 434 - 446, commitSizeDraftReview and commitMaterialDraftReview clear React
state but don't trigger DOM input events, so the textarea height remains from
previous content; attach refs (sizeDraftTextareaRef and
materialDraftTextareaRef) to the corresponding draft textareas and, after
calling setSizeDraftReview('') / setMaterialDraftReview(''), explicitly reset
the element's style height (or call autoResizeTextarea(textareaRef.current)) to
collapse it to the empty height—update commitSizeDraftReview and
commitMaterialDraftReview to call this ref-based resize logic and ensure the
textarea elements have ref={sizeDraftTextareaRef} /
ref={materialDraftTextareaRef}.

Comment on lines +30 to +33
const handleTabChange = (tab: ReviewTab) => {
if (tabParam === tab) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

tabParam === tab 비교로 초기 URL에 불필요한 ?tab=writable 추가

URL에 tab 파라미터가 없는 초기 상태(tabParam === null)에서 사용자가 이미 활성화된 "쓸 수 있는 후기" 탭을 클릭하면, null === 'writable'false이므로 가드를 통과해 ?tab=writable이 URL에 추가됩니다. tabParam 대신 이미 정규화된 activeTab과 비교해야 합니다.

🔧 제안 수정
  const handleTabChange = (tab: ReviewTab) => {
-   if (tabParam === tab) {
+   if (activeTab === tab) {
      return;
    }
    // ...
  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleTabChange = (tab: ReviewTab) => {
if (tabParam === tab) {
return;
}
const handleTabChange = (tab: ReviewTab) => {
if (activeTab === tab) {
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/reviews/review-management-content.tsx` around lines 30 - 33,
In handleTabChange, the guard currently compares tabParam to tab which causes
adding ?tab=... when tabParam is null; change the comparison to use the
normalized activeTab instead (i.e., return early if activeTab === tab) so clicks
on the already-active UI tab don't modify the URL, and ensure the subsequent
navigation/update logic only runs when the activeTab differs from the target
tab.

@Seoje1405 Seoje1405 added the 🐛 BUG 버그 수정 작업이슈 label Feb 18, 2026
@Seoje1405 Seoje1405 merged commit 4bdb12c into develop Feb 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 BUG 버그 수정 작업이슈

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants