-
Notifications
You must be signed in to change notification settings - Fork 2
[FIX]: 리뷰 탭 전환 및 리뷰 문장 입력 UX 개선 #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,11 +127,15 @@ export default function ReviewWriteFlow({ | |
| ); | ||
| const lastSavedFitIssuePartsKeyRef = useRef(''); | ||
| const lastSavedFeatureTypesKeyRef = useRef(''); | ||
| const lastGeneratedSizeAiKeyRef = useRef(''); | ||
| const lastGeneratedMaterialAiKeyRef = useRef(''); | ||
|
|
||
| const [textReview, setTextReview] = useState(''); | ||
| const [reviewImageUrls, setReviewImageUrls] = useState<string[]>([]); | ||
| const [sizeReviewItems, setSizeReviewItems] = useState<string[]>([]); | ||
| const [materialReviewItems, setMaterialReviewItems] = useState<string[]>([]); | ||
| const [sizeDraftReview, setSizeDraftReview] = useState(''); | ||
| const [materialDraftReview, setMaterialDraftReview] = useState(''); | ||
| const sizeTextareaRefs = useRef<Array<HTMLTextAreaElement | null>>([]); | ||
| const materialTextareaRefs = useRef<Array<HTMLTextAreaElement | null>>([]); | ||
| const [pendingSizeFocusIndex, setPendingSizeFocusIndex] = useState<number | null>( | ||
|
|
@@ -148,8 +152,24 @@ export default function ReviewWriteFlow({ | |
| !needsSizeSecondary || fitIssueParts.length > 0; | ||
| const canGenerateMaterialAiReview = | ||
| !needsMaterialSecondary || featureTypes.length > 0; | ||
| const canGenerateCombinedAiReview = | ||
| canGenerateSizeAiReview && canGenerateMaterialAiReview; | ||
| const sizeAiRequestKey = canGenerateSizeAiReview | ||
| ? needsSizeSecondary | ||
| ? [...fitIssueParts].sort().join('|') | ||
| : '__no-size-secondary__' | ||
| : ''; | ||
| const materialAiRequestKey = canGenerateMaterialAiReview | ||
| ? needsMaterialSecondary | ||
| ? [...featureTypes].sort().join('|') | ||
| : '__no-material-secondary__' | ||
| : ''; | ||
| const shouldGenerateSizeAiReview = | ||
| sizeAiRequestKey.length > 0 && | ||
| sizeAiRequestKey !== lastGeneratedSizeAiKeyRef.current; | ||
| const shouldGenerateMaterialAiReview = | ||
| materialAiRequestKey.length > 0 && | ||
| materialAiRequestKey !== lastGeneratedMaterialAiKeyRef.current; | ||
| const canGenerateAiReviews = | ||
| shouldGenerateSizeAiReview || shouldGenerateMaterialAiReview; | ||
|
|
||
| const uploadedImageUrls = reviewImageUrls; | ||
|
|
||
|
|
@@ -308,6 +328,8 @@ export default function ReviewWriteFlow({ | |
| setFeatureTypes([]); | ||
| lastSavedFitIssuePartsKeyRef.current = ''; | ||
| lastSavedFeatureTypesKeyRef.current = ''; | ||
| lastGeneratedSizeAiKeyRef.current = ''; | ||
| lastGeneratedMaterialAiKeyRef.current = ''; | ||
| setStep2AutoSaveStatus(''); | ||
| setSuccessMessage(''); | ||
| setStep(2); | ||
|
|
@@ -369,76 +391,58 @@ export default function ReviewWriteFlow({ | |
| } | ||
|
|
||
| setSuccessMessage('리뷰 제출 완료'); | ||
| router.replace('/reviews'); | ||
| router.replace('/reviews?tab=written'); | ||
| }); | ||
| }; | ||
|
|
||
| const handleGenerateAiReviews = () => { | ||
| setErrorMessage(''); | ||
| setSuccessMessage(''); | ||
| startTransition(async () => { | ||
| const [sizeResult, materialResult] = await Promise.all([ | ||
| generateSizeAiReviewAction(reviewId), | ||
| generateMaterialAiReviewAction(reviewId), | ||
| ]); | ||
|
|
||
| if (!sizeResult.success || !sizeResult.data) { | ||
| setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.'); | ||
| return; | ||
| } | ||
| if (!materialResult.success || !materialResult.data) { | ||
| setErrorMessage( | ||
| materialResult.message || '소재 AI 생성에 실패했습니다.', | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []); | ||
| setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []); | ||
| setSuccessMessage('AI 문장을 불러왔습니다.'); | ||
| }); | ||
| }; | ||
| if (!canGenerateAiReviews) { | ||
| setErrorMessage('새로 생성할 수 있는 문장이 없습니다.'); | ||
| return; | ||
| } | ||
|
|
||
| const handleGenerateSizeAiReview = () => { | ||
| setErrorMessage(''); | ||
| setSuccessMessage(''); | ||
| startTransition(async () => { | ||
| const result = await generateSizeAiReviewAction(reviewId); | ||
| if (!result.success || !result.data) { | ||
| setErrorMessage(result.message || '사이즈 AI 생성에 실패했습니다.'); | ||
| return; | ||
| 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; | ||
| } | ||
| setMaterialReviewItems(result.data.aiGeneratedReviews ?? []); | ||
| setSuccessMessage('소재 문장을 불러왔습니다.'); | ||
|
|
||
| setSuccessMessage('AI 문장을 불러왔습니다.'); | ||
| }); | ||
| }; | ||
|
|
||
| const handleAddSizeReviewItem = () => { | ||
| setSizeReviewItems((prev) => { | ||
| setPendingSizeFocusIndex(prev.length); | ||
| return [...prev, '']; | ||
| }); | ||
| 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(''); | ||
| }; | ||
|
Comment on lines
+434
to
446
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 드래프트 커밋 후 textarea 높이가 리셋되지 않음
🔧 제안 수정+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에 🤖 Prompt for AI Agents |
||
|
|
||
| const handleUploadReviewImages = (files: File[]) => { | ||
|
|
@@ -874,7 +878,7 @@ export default function ReviewWriteFlow({ | |
| <button | ||
| type="button" | ||
| onClick={handleGenerateAiReviews} | ||
| disabled={isPending || !canGenerateCombinedAiReview} | ||
| disabled={isPending || !canGenerateAiReviews} | ||
| className="w-full rounded-2xl bg-[#00363d] py-3 text-[24px] font-semibold leading-none text-white disabled:opacity-60" | ||
| > | ||
| 후기 문장 받아보기 | ||
|
|
@@ -889,34 +893,13 @@ export default function ReviewWriteFlow({ | |
| <Image src="/icons/ai-star.svg" alt="" width={18} height={18} /> | ||
| <p className="text-2xl font-semibold text-[#1c1c1c]">사이즈 관련</p> | ||
| </div> | ||
| <p className="text-sm text-[#7d7d7d]"> | ||
| {sizeReviewItems.length === 0 | ||
| ? '버튼을 클릭해서 문장을 생성할 수 있어요' | ||
| : '각 문장은 클릭해서 수정 할 수 있어요'} | ||
| <p className="text-base text-[#7d7d7d]"> | ||
| 후기 문장을 받거나 문장을 직접 추가할 수 있어요 | ||
| </p> | ||
| {sizeReviewItems.length === 0 ? ( | ||
| <div className="space-y-2"> | ||
| <button | ||
| type="button" | ||
| onClick={handleGenerateSizeAiReview} | ||
| disabled={isPending || !canGenerateSizeAiReview} | ||
| className="w-full rounded-lg bg-[#00363d] py-2 text-lg font-semibold text-white disabled:opacity-60" | ||
| > | ||
| 사이즈 문장 생성 | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={handleAddSizeReviewItem} | ||
| className="w-full rounded-lg border border-[#999] bg-white py-2 text-base font-medium text-[#1c1c1c]" | ||
| > | ||
| 직접 문장 추가 | ||
| </button> | ||
| </div> | ||
| ) : ( | ||
| <div className="space-y-2"> | ||
| <div className="divide-y divide-[#e3e3e3] rounded-md bg-white"> | ||
| {sizeReviewItems.map((item, index) => ( | ||
| <div key={`size-review-${index}`} className="flex items-center"> | ||
| <div className="space-y-2"> | ||
| <div className="divide-y divide-[#e3e3e3] rounded-md bg-white"> | ||
| {sizeReviewItems.map((item, index) => ( | ||
| <div key={`size-review-${index}`} className="flex items-center"> | ||
| <textarea | ||
| rows={1} | ||
| ref={(element) => { | ||
|
|
@@ -931,7 +914,7 @@ export default function ReviewWriteFlow({ | |
| ); | ||
| } | ||
| }} | ||
| className="min-h-[56px] w-full resize-none overflow-hidden rounded-md border border-transparent bg-transparent px-4 py-4 text-lg leading-[1.35] font-medium whitespace-pre-wrap break-words text-[#1c1c1c] outline-none focus:border-ongil-teal focus:outline focus:outline-1 focus:outline-ongil-teal" | ||
| className="min-h-[56px] w-full resize-none overflow-hidden rounded-md border border-[#d1d1d1] bg-transparent px-4 py-4 text-lg leading-[1.35] font-medium whitespace-pre-wrap break-words text-[#1c1c1c] outline-none focus:border-ongil-teal focus:outline focus:outline-1 focus:outline-ongil-teal" | ||
| value={item} | ||
| onChange={(e) => { | ||
| const next = [...sizeReviewItems]; | ||
|
|
@@ -951,53 +934,54 @@ export default function ReviewWriteFlow({ | |
| > | ||
| × | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ))} | ||
| <div className="flex items-center"> | ||
| <textarea | ||
| rows={1} | ||
| enterKeyHint="done" | ||
| placeholder="문장을 직접 입력해 주세요" | ||
| onInput={(e) => autoResizeTextarea(e.currentTarget)} | ||
| onKeyDown={(e) => { | ||
| const nativeEvent = e.nativeEvent as KeyboardEvent; | ||
| if ( | ||
| e.key === 'Enter' && | ||
| !e.shiftKey && | ||
| !nativeEvent.isComposing | ||
| ) { | ||
| e.preventDefault(); | ||
| commitSizeDraftReview(); | ||
| } | ||
| }} | ||
| className="min-h-[56px] w-full resize-none overflow-hidden rounded-md border border-[#d1d1d1] bg-transparent px-4 py-4 text-lg leading-[1.35] font-medium whitespace-pre-wrap break-words text-[#1c1c1c] outline-none focus:border-ongil-teal focus:outline focus:outline-1 focus:outline-ongil-teal" | ||
| value={sizeDraftReview} | ||
| onChange={(e) => setSizeDraftReview(e.target.value)} | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={commitSizeDraftReview} | ||
| className="px-4 text-[28px] leading-none text-[#8e8e8e]" | ||
| aria-label="사이즈 문장 추가" | ||
| > | ||
| + | ||
| </button> | ||
| </div> | ||
| <button | ||
| type="button" | ||
| onClick={handleAddSizeReviewItem} | ||
| className="w-full rounded-lg border border-[#999] bg-white py-2 text-base font-medium text-[#1c1c1c]" | ||
| > | ||
| 직접 문장 추가 | ||
| </button> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="space-y-3 bg-white px-5 py-4"> | ||
| <div className="flex items-center gap-2"> | ||
| <Image src="/icons/ai-star.svg" alt="" width={18} height={18} /> | ||
| <p className="text-2xl font-semibold text-[#1c1c1c]">소재 관련</p> | ||
| </div> | ||
| <p className="text-sm text-[#7d7d7d]"> | ||
| {materialReviewItems.length === 0 | ||
| ? '버튼을 클릭해서 문장을 생성할 수 있어요' | ||
| : '각 문장은 클릭해서 수정 할 수 있어요'} | ||
| <p className="text-base text-[#7d7d7d]"> | ||
| 후기 문장을 받거나 문장을 직접 추가할 수 있어요 | ||
| </p> | ||
| {materialReviewItems.length === 0 ? ( | ||
| <div className="space-y-2"> | ||
| <button | ||
| type="button" | ||
| onClick={handleGenerateMaterialAiReview} | ||
| disabled={isPending || !canGenerateMaterialAiReview} | ||
| className="w-full rounded-lg bg-[#00363d] py-2 text-lg font-semibold text-white disabled:opacity-60" | ||
| > | ||
| 소재 문장 생성 | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={handleAddMaterialReviewItem} | ||
| className="w-full rounded-lg border border-[#999] bg-white py-2 text-base font-medium text-[#1c1c1c]" | ||
| > | ||
| 직접 문장 추가 | ||
| </button> | ||
| </div> | ||
| ) : ( | ||
| <div className="space-y-2"> | ||
| <div className="divide-y divide-[#e3e3e3] rounded-md bg-white"> | ||
| {materialReviewItems.map((item, index) => ( | ||
| <div key={`material-review-${index}`} className="flex items-center"> | ||
| <div className="space-y-2"> | ||
| <div className="divide-y divide-[#e3e3e3] rounded-md bg-white"> | ||
| {materialReviewItems.map((item, index) => ( | ||
| <div key={`material-review-${index}`} className="flex items-center"> | ||
| <textarea | ||
| rows={1} | ||
| ref={(element) => { | ||
|
|
@@ -1012,7 +996,7 @@ export default function ReviewWriteFlow({ | |
| ); | ||
| } | ||
| }} | ||
| className="min-h-[56px] w-full resize-none overflow-hidden rounded-md border border-transparent bg-transparent px-4 py-4 text-lg leading-[1.35] font-medium whitespace-pre-wrap break-words text-[#1c1c1c] outline-none focus:border-ongil-teal focus:outline focus:outline-1 focus:outline-ongil-teal" | ||
| className="min-h-[56px] w-full resize-none overflow-hidden rounded-md border border-[#d1d1d1] bg-transparent px-4 py-4 text-lg leading-[1.35] font-medium whitespace-pre-wrap break-words text-[#1c1c1c] outline-none focus:border-ongil-teal focus:outline focus:outline-1 focus:outline-ongil-teal" | ||
| value={item} | ||
| onChange={(e) => { | ||
| const next = [...materialReviewItems]; | ||
|
|
@@ -1032,25 +1016,47 @@ export default function ReviewWriteFlow({ | |
| > | ||
| × | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ))} | ||
| <div className="flex items-center"> | ||
| <textarea | ||
| rows={1} | ||
| enterKeyHint="done" | ||
| placeholder="문장을 직접 입력해 주세요" | ||
| onInput={(e) => autoResizeTextarea(e.currentTarget)} | ||
| onKeyDown={(e) => { | ||
| const nativeEvent = e.nativeEvent as KeyboardEvent; | ||
| if ( | ||
| e.key === 'Enter' && | ||
| !e.shiftKey && | ||
| !nativeEvent.isComposing | ||
| ) { | ||
| e.preventDefault(); | ||
| commitMaterialDraftReview(); | ||
| } | ||
| }} | ||
| className="min-h-[56px] w-full resize-none overflow-hidden rounded-md border border-[#d1d1d1] bg-transparent px-4 py-4 text-lg leading-[1.35] font-medium whitespace-pre-wrap break-words text-[#1c1c1c] outline-none focus:border-ongil-teal focus:outline focus:outline-1 focus:outline-ongil-teal" | ||
| value={materialDraftReview} | ||
| onChange={(e) => setMaterialDraftReview(e.target.value)} | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={commitMaterialDraftReview} | ||
| className="px-4 text-[28px] leading-none text-[#8e8e8e]" | ||
| aria-label="소재 문장 추가" | ||
| > | ||
| + | ||
| </button> | ||
| </div> | ||
| <button | ||
| type="button" | ||
| onClick={handleAddMaterialReviewItem} | ||
| className="w-full rounded-lg border border-[#999] bg-white py-2 text-base font-medium text-[#1c1c1c]" | ||
| > | ||
| 직접 문장 추가 | ||
| </button> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
|
|
||
| <label className="mx-5 block text-sm"> | ||
| <span className="text-2xl font-semibold text-black">기타</span> | ||
| <textarea | ||
| placeholder="추가로 하고싶은 말을 적어주세요" | ||
| className="mt-3 min-h-24 w-full rounded border border-[#cfcfcf] px-3 py-2" | ||
| className="mt-3 min-h-24 w-full rounded border border-[#cfcfcf] px-3 py-2 text-base" | ||
| value={textReview} | ||
| onChange={(e) => setTextReview(e.target.value)} | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
사이즈 AI 생성 실패 시 소재 AI 생성이 묵묵히 스킵됨
Line 412의
return은startTransition콜백 전체를 종료시킵니다.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
🤖 Prompt for AI Agents