Conversation
📝 WalkthroughWalkthrough상품 등록 흐름이 크게 재구성되었습니다. Context/Provider 기반 스텝 관리는 삭제되고 Zustand 헤더 스토어로 대체되었으며, 폼 타입·스키마가 통합·재정의되고 옵션은 인덱스 기반 field-array로 전환되었습니다. 썸네일 업로드, 임시저장, 미리보기, 에디터, 제출용 FormData 매핑 등이 새로 추가되었습니다. Changes
Optional 주요 체크 항목 (우선 점검 권장)
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 56
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
packages/ui/src/accordion/accordion.tsx (1)
36-65:⚠️ Potential issue | 🟠 Major
customIconprop의 타입과 동작이 불일치합니다원인: 타입이
React.ReactNode인데 기본값은false이고 실제로는 truthy/falsy 플래그로만 사용되어 클래스만 토글됩니다(아이콘 교체 X). 영향: 사용자가customIcon={<MyIcon />}을 전달하면 타입상 허용되지만MyIcon은 렌더되지 않고 기본ChevronDownIcon이 그대로 표시되어 혼동/버그 유발. 대안: 의도가 "스타일 토글"이라면boolean으로 타입 변경하고 prop명을iconClassName/largeIcon등으로 바꾸거나, 의도가 "아이콘 교체"라면customIcon이 truthy일 때 해당 노드로ChevronDownIcon을 대체해 주세요.♻️ 제안 1: 의도가 스타일 토글인 경우
interface CustomTriggerProps extends React.ComponentProps<typeof AccordionPrimitive.Trigger> { - customIcon?: React.ReactNode + largeIcon?: boolean } function AccordionTrigger({ className, children, - customIcon = false, + largeIcon = false, ...props }: CustomTriggerProps) { ... <ChevronDownIcon fontSize={36} data-slot="accordion-trigger-icon" className={cn( 'pointer-events-none shrink-0 transition-transform group-data-[state=open]:rotate-180', - customIcon && 'm-0! size-9!', + largeIcon && 'm-0! size-9!', )} />♻️ 제안 2: 의도가 아이콘 교체인 경우
- <ChevronDownIcon - fontSize={36} - data-slot="accordion-trigger-icon" - className={cn( - 'pointer-events-none shrink-0 transition-transform group-data-[state=open]:rotate-180', - customIcon && 'm-0! size-9!', - )} - /> + {customIcon ? ( + customIcon + ) : ( + <ChevronDownIcon + data-slot="accordion-trigger-icon" + className="pointer-events-none shrink-0 transition-transform group-data-[state=open]:rotate-180" + /> + )}apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx (1)
2-2: 🧹 Nitpick | 🔵 TrivialOptional 정리 항목 (3건)
- 사용되지 않는
ScrollAreaimport 제거(line 2).- 주석 처리된 옛
<main>블록(line 13–17) 제거 — 히스토리는 git에 남습니다.max-w-[calc(100vw-240px)]의240px은 Lnb 폭 매직넘버이므로 공용 토큰/상수로 추출 권장.Also applies to: 13-17
apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts (2)
32-43:⚠️ Potential issue | 🟠 Major
valundefined 시 런타임 오류 가능
noticeValue[field.key]가undefined이면val.trim()에서 TypeError가 납니다. 폼 초기값이 누락된 케이스(임시저장 복원 등)에서 터질 수 있으니 옵셔널 체이닝 또는 기본값 처리가 필요합니다.🛠 제안 수정
DISCLOSURE_FIELDS.forEach((field) => { if (noticeMode[field.key] === 'manual') { - const val = noticeValue[field.key] - if (val.trim().length < 3 || val.trim().length >= 50) { + const val = noticeValue[field.key] ?? '' + const len = val.trim().length + if (len < 3 || len >= 50) { ctx.addIssue({
35-38:⚠️ Potential issue | 🟡 Minor경계 조건 확인: "50자 미만" vs "50자 이하"
현재는 정확히 50자를 입력해도 에러가 납니다. 메시지 "3자 이상 50자 미만"과는 일치하지만 일반적인 UX("이하" 허용)와 다를 수 있어 기획 의도를 확인 부탁드립니다.
apps/seller/src/features/products/create/create-form-options/create-options.schema.ts (1)
5-32: 🧹 Nitpick | 🔵 Trivial
''을 enum에 포함시키는 대신 optional/transform 권장원인:
z.enum(['bread','snack',''])+refine으로 빈 문자열을 다시 막는 방식은 타입('bread'|'snack'|'')에도 불필요한''이 새어 나가 다운스트림(매퍼/요청 타입) 모두를 오염시킴. → 영향: 클라이언트 타입 안전성 저하, 분기 누락 위험. → 대안:z.enum(['bread','snack']).or(z.literal('').transform(()=>undefined)).pipe(z.enum(['bread','snack']))또는 폼 입력은'bread'|'snack'|undefined로 모델링하고 제출 단계에서만 강제.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2f2ccfa5-e491-429c-8191-a710ab7b1b56
⛔ Files ignored due to path filters (5)
apps/seller/src/assets/icons/preview/noimage.svgis excluded by!**/*.svgand included byapps/**/src/**apps/seller/src/assets/icons/preview/preview-icon1.pngis excluded by!**/*.pngand included byapps/**/src/**apps/seller/src/assets/icons/preview/preview-icon2.pngis excluded by!**/*.pngand included byapps/**/src/**apps/seller/src/assets/icons/preview/preview-icon3.pngis excluded by!**/*.pngand included byapps/**/src/**apps/seller/vite.config.tsis excluded by none and included by none
📒 Files selected for processing (76)
apps/seller/src/entity/products/create/create-delivery/index.tsapps/seller/src/entity/products/create/create-disclosure/index.tsapps/seller/src/entity/products/create/create-disclosure/product-disclosure.constant.tsapps/seller/src/entity/products/create/create-form/create-form.types.tsapps/seller/src/entity/products/create/create-form/create-indivisual-form.type.tsapps/seller/src/entity/products/create/create-form/create.api.tsapps/seller/src/entity/products/create/create-form/create.query.tsapps/seller/src/entity/products/create/create-form/create.type.tsapps/seller/src/entity/products/create/create-form/index.tsapps/seller/src/entity/products/create/create-header/index.tsapps/seller/src/entity/products/create/create-info/index.tsapps/seller/src/entity/products/create/create-info/product-discount-type.constants.tsapps/seller/src/entity/products/create/create-info/production-time.constants.tsapps/seller/src/entity/products/create/create-options/index.tsapps/seller/src/entity/products/create/create-store/create-store-header.type.tsapps/seller/src/entity/products/create/create-store/index.tsapps/seller/src/entity/products/index.tsapps/seller/src/features/products/create/create-calculation/create-form-number-input.hook.tsapps/seller/src/features/products/create/create-calculation/index.tsapps/seller/src/features/products/create/create-draft/create-draft-dialog.ui.tsxapps/seller/src/features/products/create/create-draft/create-draft.store.tsxapps/seller/src/features/products/create/create-draft/index.tsapps/seller/src/features/products/create/create-draft/use-create-draft.hook.tsapps/seller/src/features/products/create/create-footer/create-footer.ui.tsxapps/seller/src/features/products/create/create-footer/index.tsapps/seller/src/features/products/create/create-form-delivery/create-form-delivery-area.ui.tsxapps/seller/src/features/products/create/create-form-delivery/use-product-delivery-form.hook.tsapps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsxapps/seller/src/features/products/create/create-form-detail/create-form-detail-area.ui.tsxapps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.tsapps/seller/src/features/products/create/create-form-disclosure/create-form-disclosure-area.ui.tsxapps/seller/src/features/products/create/create-form-disclosure/use-product-disclosure-form.hook.tsapps/seller/src/features/products/create/create-form-info/create-form-info-area.ui.tsxapps/seller/src/features/products/create/create-form-info/create-info.schema.tsapps/seller/src/features/products/create/create-form-info/use-product-info-form.hook.tsapps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsxapps/seller/src/features/products/create/create-form-options/create-form-options-form.ui.tsxapps/seller/src/features/products/create/create-form-options/create-options.schema.tsapps/seller/src/features/products/create/create-form-options/index.tsapps/seller/src/features/products/create/create-form-options/use-product-options.form.hook.tsapps/seller/src/features/products/create/create-form-thumbnail-upload/create-form-thumbnail-upload-area.ui.tsxapps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.tsapps/seller/src/features/products/create/create-form-thumbnail-upload/index.tsapps/seller/src/features/products/create/create-form-thumbnail-upload/use-product-thumnail-form.hook.tsapps/seller/src/features/products/create/create-form/create-form-container.ui.tsxapps/seller/src/features/products/create/create-form/create-form-mapper.tsapps/seller/src/features/products/create/create-form/create-form-provider.ui.tsxapps/seller/src/features/products/create/create-form/create-form-steps.context.tsapps/seller/src/features/products/create/create-form/create-form-submit.mutation.tsapps/seller/src/features/products/create/create-form/create-form.schema.tsapps/seller/src/features/products/create/create-form/index.tsapps/seller/src/features/products/create/create-form/product-create.types.tsapps/seller/src/features/products/create/create-form/product-final-price.ui.tsxapps/seller/src/features/products/create/create-form/use-create-form-steps.hook.tsapps/seller/src/features/products/create/create-form/use-create-form.hook.tsapps/seller/src/features/products/create/create-form/use-create-product-form.hook.tsapps/seller/src/features/products/create/create-header/create-header-tags.ui.tsxapps/seller/src/features/products/create/create-header/create-header.ui.tsxapps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsxapps/seller/src/features/products/create/create-preview/create-preview-option-item.ui.tsxapps/seller/src/features/products/create/create-preview/create-preview.hook.tsapps/seller/src/features/products/create/create-preview/index.tsapps/seller/src/features/products/create/create-store/create-header-store.store.tsapps/seller/src/features/products/create/create-store/index.tsapps/seller/src/features/products/create/create-store/product-creation.store.tsapps/seller/src/features/products/create/create-store/use-create-header-steps.hook.tsxapps/seller/src/features/products/create/index.tsapps/seller/src/main.tsxapps/seller/src/pages/products/create/create-page.tsxapps/seller/src/pages/products/create/detail-edit-page.tsxapps/seller/src/shared/block/fixed-layout/fixed-layout.tsxpackages/ui/src/accordion/accordion.tsxpackages/ui/src/chip/chip.tsxpackages/ui/src/editor/editor.csspackages/ui/src/editor/editor.tsxpackages/ui/src/tab/stage-tab.tsx
💤 Files with no reviewable changes (8)
- apps/seller/src/features/products/create/create-form/create-form-provider.ui.tsx
- apps/seller/src/features/products/create/create-form/product-create.types.ts
- apps/seller/src/features/products/create/create-form/use-create-form-steps.hook.ts
- apps/seller/src/features/products/create/create-form/create-form-steps.context.ts
- apps/seller/src/pages/products/create/detail-edit-page.tsx
- apps/seller/src/features/products/create/create-form/use-create-product-form.hook.ts
- apps/seller/src/entity/products/index.ts
- apps/seller/src/main.tsx
| { label: '7. 보관방법', key: 'storageGuide' }, | ||
| { label: '8. 포장 단위 별 내용물 용량(중량) 수량', key: 'packagingContents' }, | ||
| { label: '9. 포장 단위별 수량', key: 'packagingQuantityUnit' }, | ||
| // { label: '7. 보관방법', key: 'storageGuide' }, |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
주석 처리된 코드 제거 권장
원인: storageGuide 항목이 주석으로 남아 의도가 불명확합니다.
영향: 향후 유지보수 시 혼란, dead code 누적.
대안: 영구 제외라면 주석 라인 삭제, 일시적이라면 이유를 짧게 명시하세요.
| export type CreateFormType = ProductFormInput & | ||
| DeliveryFormInput & { | ||
| options: ProductOptionFormInput[] // Feature의 스키마 대신 Entity의 순수 타입을 사용 | ||
| } & ProductDisclosureFormInput & | ||
| ThumbnailFormInput |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
교집합 타입 시 키 충돌 주의 (Optional)
여러 폼 타입을 &로 합치면 동일 키가 서로 다른 타입을 가질 경우 해당 키가 never로 추론됩니다. 향후 폼 인풋 타입 확장 시 키 네임스페이스(예: product*, delivery*) 규칙을 유지해 주세요.
|
|
||
| export type ProductOptionFormInput = { | ||
| mainCategory: string | ||
| mainCategory: 'bread' | 'snack' | '' |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Optional: mainCategory 유니온에 '' 포함은 도메인 누수
미선택 상태를 ''로 표현하면 매퍼/요청 타입까지 빈 문자열 분기를 요구하게 됩니다. 'bread'|'snack'로 좁히고 미선택은 undefined(또는 별도 nullable)로 모델링하는 편이 타입 안전성과 의도 표현이 더 명확합니다 (File 4 스키마와 함께 정렬 필요).
| } | ||
|
|
||
| // 상품 등록 | ||
| export const createProduct = async (formData: FormData) => { |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
반환 타입 any 회피 권장
응답 스키마(ApiResponse<…>)를 제네릭으로 지정해 호출부의 타입 안전성을 확보해 주세요. 추후 result 구조 변경 시 컴파일러가 회귀를 잡아줍니다.
| export const createProduct = async (formData: FormData) => { | ||
| const response = await client.post('/api/v1/seller/boards', formData, { | ||
| headers: { | ||
| 'Content-Type': 'multipart/form-data', | ||
| }, | ||
| }) | ||
| return response.data | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does axios automatically set multipart/form-data boundary when posting FormData, and what happens if Content-Type header is manually set without boundary?
💡 Result:
Yes, axios automatically sets the Content-Type: multipart/form-data header with the correct boundary when posting a FormData object, by detecting the FormData payload and either letting the browser/network adapter handle it or using the FormData instance's headers (like getHeaders in Node.js with form-data polyfill). This has been the standard behavior since early versions, with improvements in v0.27+ for better Node.js support via form-data dependency. If the Content-Type header is manually set to multipart/form-data without a boundary parameter (e.g., { headers: { 'Content-Type': 'multipart/form-data' } }), it can cause problems: - In browser XMLHttpRequest adapter (default): Axios historically avoided overriding the header to let the browser set the boundary; manual setting without boundary may lead to missing boundary, causing servers to reject with "Multipart: Boundary not found". - In Node.js http adapter: Uses FormData's getHeaders which includes boundary, but manual override without boundary breaks parsing. - In fetch adapter (recent fix in 2026 commit 62f6281): Axios now detects and removes manual Content-Type: multipart/form-data without boundary when data is FormData, allowing native fetch to set the correct header with boundary. Recommendation: Do not manually set Content-Type when using FormData—let axios auto-detect and set it correctly. Manual setting is only needed for the automatic object-to-FormData serialization feature (v0.27+), but even then, omit the boundary.
Citations:
- 1: https://axios-http.com/docs/multipart
- 2: Axios fetch adapter: Missing boundary when Content-Type is set manually with FormData axios/axios#7054
- 3: https://stackoverflow.com/questions/49579640/how-to-send-data-correct-axios-error-multipart-boundary-not-found
- 4: https://stackoverflow.com/questions/75742226/cant-set-content-type-header-for-axios-with-formdata
- 5: FormData request header doesn't include boundary axios/axios#4631
- 6: axios/axios@62f6281
- 7: Automatic Content-Type for FormData uploads axios/axios#22
- 8: https://github.com/axios/axios/tree/main
FormData 전송 시 수동으로 Content-Type 헤더 설정하지 말 것
axios가 FormData를 감지하여 boundary를 포함한 헤더를 자동 설정하므로, 수동으로 'Content-Type': 'multipart/form-data'를 지정하면 boundary가 누락되어 서버가 요청을 파싱하지 못합니다. 수동 설정 시 브라우저와 Node.js 환경 모두에서 "Multipart: Boundary not found" 오류가 발생할 수 있습니다.
제안 수정
export const createProduct = async (formData: FormData) => {
- const response = await client.post('/api/v1/seller/boards', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- })
+ const response = await client.post('/api/v1/seller/boards', formData)
return response.data
}📝 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.
| export const createProduct = async (formData: FormData) => { | |
| const response = await client.post('/api/v1/seller/boards', formData, { | |
| headers: { | |
| 'Content-Type': 'multipart/form-data', | |
| }, | |
| }) | |
| return response.data | |
| } | |
| export const createProduct = async (formData: FormData) => { | |
| const response = await client.post('/api/v1/seller/boards', formData) | |
| return response.data | |
| } |
| .quill-wrapper .ql-toolbar { | ||
| border: none; | ||
| border-bottom: 1px solid var(--color-gray-200); | ||
| padding: 8px 12px; | ||
| padding: 20px 48px; | ||
| font-family: inherit; | ||
| position: sticky; | ||
| top: 0; | ||
| z-index: 10; | ||
| background-color: white; | ||
| width: 100vw; | ||
| left: 0; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP --type=ts --type=tsx -C2 "<Editor[\s>]"Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 116
🏁 Script executed:
rg -nP -C2 "<Editor[\s>]" --type ts --type jsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1059
🏁 Script executed:
rg "import.*Editor" --type ts --type js -A 2 -B 2Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1813
🏁 Script executed:
fd -e ts -e js -e tsx | head -20Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 984
🏁 Script executed:
rg -C5 "ProductEditorModal" --type ts --type jsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 5534
🏁 Script executed:
rg -C5 "Editor" apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsxRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1220
🏁 Script executed:
rg "width.*100vw|100%" packages/ui/src/editor/ --type css --type scssRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 117
width: 100vw는 비-풀스크린 사용처에서 레이아웃 파괴
스토리북 문서에서 Editor가 700px 폭의 컨테이너에 사용되고 있으므로, 실제로 모달 외 환경에서도 쓰입니다. 이 경우 width: 100vw는 부모 폭을 무시하고 뷰포트 전체로 확장되어 가로 스크롤을 일으킵니다. 풀스크린 모달 전용 스타일로 분리하거나 modifier 클래스 조건으로 적용하세요.
| /* 에디터 전용 스타일 */ | ||
| /* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */ | ||
| .ql-editor h1 { | ||
| font-size: 2em; | ||
| font-weight: bold; | ||
| margin: 0.67em 0; | ||
| } | ||
|
|
||
| .ql-editor h2 { | ||
| font-size: 1.5em; | ||
| font-weight: bold; | ||
| margin: 0.75em 0; | ||
| } | ||
|
|
||
| .ql-editor h3 { | ||
| font-size: 1.17em; | ||
| font-weight: bold; | ||
| margin: 0.83em 0; | ||
| } | ||
|
|
||
| .ql-editor strong { | ||
| font-weight: bold; | ||
| } | ||
|
|
||
| .ql-editor em { | ||
| font-style: italic; | ||
| } | ||
|
|
||
| .ql-editor u { | ||
| text-decoration: underline; | ||
| } | ||
|
|
||
| .ql-editor s { | ||
| text-decoration: line-through; | ||
| } | ||
|
|
||
| .ql-editor ol li { | ||
| list-style-type: decimal; | ||
| padding-left: 1.5em; | ||
| } | ||
|
|
||
| .ql-editor ul li { | ||
| list-style-type: disc; | ||
| padding-left: 1.5em; | ||
| } | ||
|
|
||
| .ql-editor a { | ||
| color: #06c; | ||
| text-decoration: underline; | ||
| } |
There was a problem hiding this comment.
전역 스타일 누수 — .quill-wrapper 스코프 누락
.ql-editor h1/h2/h3/strong/... 셀렉터가 래퍼 클래스 없이 전역에 적용되어, 앱 내 다른 Quill 인스턴스의 렌더링까지 영향을 받습니다. 디자인 시스템 패키지에서 누수되면 모든 소비자에 파급됩니다. 모든 셀렉터를 .quill-wrapper .ql-editor ...로 한정하세요.
♻️ 제안 수정 (대표 예시)
-.ql-editor h1 {
+.quill-wrapper .ql-editor h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
}(나머지 h2, h3, strong, em, u, s, ol li, ul li, a도 동일하게 스코프 적용)
📝 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.
| /* 에디터 전용 스타일 */ | |
| /* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */ | |
| .ql-editor h1 { | |
| font-size: 2em; | |
| font-weight: bold; | |
| margin: 0.67em 0; | |
| } | |
| .ql-editor h2 { | |
| font-size: 1.5em; | |
| font-weight: bold; | |
| margin: 0.75em 0; | |
| } | |
| .ql-editor h3 { | |
| font-size: 1.17em; | |
| font-weight: bold; | |
| margin: 0.83em 0; | |
| } | |
| .ql-editor strong { | |
| font-weight: bold; | |
| } | |
| .ql-editor em { | |
| font-style: italic; | |
| } | |
| .ql-editor u { | |
| text-decoration: underline; | |
| } | |
| .ql-editor s { | |
| text-decoration: line-through; | |
| } | |
| .ql-editor ol li { | |
| list-style-type: decimal; | |
| padding-left: 1.5em; | |
| } | |
| .ql-editor ul li { | |
| list-style-type: disc; | |
| padding-left: 1.5em; | |
| } | |
| .ql-editor a { | |
| color: #06c; | |
| text-decoration: underline; | |
| } | |
| /* 에디터 전용 스타일 */ | |
| /* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */ | |
| .quill-wrapper .ql-editor h1 { | |
| font-size: 2em; | |
| font-weight: bold; | |
| margin: 0.67em 0; | |
| } | |
| .quill-wrapper .ql-editor h2 { | |
| font-size: 1.5em; | |
| font-weight: bold; | |
| margin: 0.75em 0; | |
| } | |
| .quill-wrapper .ql-editor h3 { | |
| font-size: 1.17em; | |
| font-weight: bold; | |
| margin: 0.83em 0; | |
| } | |
| .quill-wrapper .ql-editor strong { | |
| font-weight: bold; | |
| } | |
| .quill-wrapper .ql-editor em { | |
| font-style: italic; | |
| } | |
| .quill-wrapper .ql-editor u { | |
| text-decoration: underline; | |
| } | |
| .quill-wrapper .ql-editor s { | |
| text-decoration: line-through; | |
| } | |
| .quill-wrapper .ql-editor ol li { | |
| list-style-type: decimal; | |
| padding-left: 1.5em; | |
| } | |
| .quill-wrapper .ql-editor ul li { | |
| list-style-type: disc; | |
| padding-left: 1.5em; | |
| } | |
| .quill-wrapper .ql-editor a { | |
| color: `#06c`; | |
| text-decoration: underline; | |
| } |
| const Component = onStepClick ? 'button' : 'div' | ||
| const componentProps = onStepClick | ||
| ? { type: 'button' as const, onClick: () => onStepClick(index) } | ||
| : {} | ||
|
|
||
| return ( | ||
| <div key={step} className="flex shrink-0 items-center gap-2"> | ||
| <Component | ||
| key={step} | ||
| {...componentProps} | ||
| className="flex shrink-0 items-center gap-2" | ||
| > |
There was a problem hiding this comment.
버튼 렌더링 시 브라우저 기본 스타일 리셋 필요
Component가 button이 되면 UA 기본 background/border/padding/cursor가 적용되어 div와 시각적 차이가 발생합니다. 클릭 가능 단계만 외형이 어긋날 수 있어 리셋이 필요합니다.
♻️ 제안 수정
return (
<Component
key={step}
{...componentProps}
- className="flex shrink-0 items-center gap-2"
+ className={cn(
+ 'flex shrink-0 items-center gap-2',
+ onStepClick && 'cursor-pointer border-0 bg-transparent p-0',
+ )}
>📝 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.
| const Component = onStepClick ? 'button' : 'div' | |
| const componentProps = onStepClick | |
| ? { type: 'button' as const, onClick: () => onStepClick(index) } | |
| : {} | |
| return ( | |
| <div key={step} className="flex shrink-0 items-center gap-2"> | |
| <Component | |
| key={step} | |
| {...componentProps} | |
| className="flex shrink-0 items-center gap-2" | |
| > | |
| const Component = onStepClick ? 'button' : 'div' | |
| const componentProps = onStepClick | |
| ? { type: 'button' as const, onClick: () => onStepClick(index) } | |
| : {} | |
| return ( | |
| <Component | |
| key={step} | |
| {...componentProps} | |
| className={cn( | |
| 'flex shrink-0 items-center gap-2', | |
| onStepClick && 'cursor-pointer border-0 bg-transparent p-0', | |
| )} | |
| > |
| const handleSaveDraft = () => { | ||
| const formData = form.getValues() | ||
| saveDraft({ | ||
| ...formData, | ||
| productDetail, | ||
| }) | ||
| toast.success('임시저장을 완료했어요 ') | ||
| } | ||
|
|
||
| // 폼에 임시저장 데이터 복원 | ||
| const handleRestoreDraft = () => { | ||
| if (!draft) return | ||
| const { productDetail: savedDetail, ...formValues } = draft | ||
| form.reset(formValues as CreateFormType) | ||
| useProductCreationStore.getState().setProductDetail(savedDetail) | ||
| clearDraft() | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# persist 설정과 저장 대상 확인
fd -t f 'create-draft.store' | xargs rg -n -C 3 'persist|storage|partialize'Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 433
🏁 Script executed:
# 저장소 전체 내용 확인
fd -t f 'create-draft.store' | head -5Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 165
🏁 Script executed:
# CreateFormType 정의 확인
rg -n "type CreateFormType|interface CreateFormType" -A 10Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 665
🏁 Script executed:
# 폼 설정 및 초기값 확인
fd -t f 'create-form' | xargs rg -l "mainImage|extraImages" | head -5Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 351
🏁 Script executed:
# handleRestoreDraft 전체 함수와 검증 로직 확인
rg -n "handleRestoreDraft|form.reset|form.trigger" -B 2 -A 5Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 6036
🏁 Script executed:
cat apps/seller/src/features/products/create/create-draft/create-draft.store.tsxRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 752
🏁 Script executed:
# ThumbnailFormInput과 ProductFormInput 정의 확인
rg -n "type ThumbnailFormInput|interface ThumbnailFormInput|type ProductFormInput|interface ProductFormInput" -A 15Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 3617
🏁 Script executed:
# mainImage/extraImages 필드 타입 정의
rg -n "mainImage|extraImages" -B 2 -A 2 | head -50Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 5404
🏁 Script executed:
# use-create-draft.hook.ts 전체 파일 확인
cat apps/seller/src/features/products/create/create-draft/use-create-draft.hook.tsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1134
🏁 Script executed:
# 복원 후 검증/제출 흐름 확인
rg -n "handleRestoreDraft" -A 10 -B 5Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 4848
🏁 Script executed:
# 이미지 필드 검증 규칙 다시 확인
cat apps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.tsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 403
복원 후 이미지 필드 재입력 안내 필요
임시저장 데이터 복원 시 mainImage/extraImages는 저장소에서 의도적으로 제외되므로, form.reset()으로 null/[]로 설정됩니다. 스키마에서 mainImage는 필수인데 복원 후 경고 없이 빈 상태가 되어 사용자가 재입력 필요를 알 수 없습니다.
개선안: handleRestoreDraft() 후 사용자에게 이미지 재업로드 안내 토스트를 표시하세요.
| key={field.id} | ||
| index={index} | ||
| isLast={index === fields.length - 1} | ||
| onDelete={() => fields.length > 1 && remove(index)} |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
삭제 무반응 시 사용자 피드백 부재 (Optional)
마지막 1개 옵션일 때 remove가 silent하게 무시돼요. 사용자는 클릭이 먹히지 않는 것처럼 느껴요. 비활성화 처리나 토스트 안내(예: "옵션은 1개 이상 필요해요")를 권장해요.
| }, [ | ||
| index, | ||
| nutritionInputs.sugar?.displayValue, | ||
| nutritionInputs.protein?.displayValue, | ||
| nutritionInputs.fat?.displayValue, | ||
| ingredientCategories, // Zustand 내부 비교 최적화 가능 | ||
| setNutritionData, | ||
| ]) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -t f 'use-product-options.form.hook' --exec catRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 5385
🏁 Script executed:
fd -t f 'create-form-options-form.ui.tsx' --exec catRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 9832
form.watch() 반환 값 안정화 필요 - 매 렌더 새 배열 참조 생성
useProductOptionForm 훅에서 ingredientCategories = form.watch()로 직접 사용하고 있어, react-hook-form의 watch()가 매 렌더 새 배열 참조를 반환하면 useEffect가 불필요하게 매 렌더마다 트리거되어 Zustand 업데이트 → 재렌더 사이클이 발생할 수 있습니다.
해결안: 훅에서 useMemo()로 ingredientCategories 참조를 안정화하거나, 컴포넌트에서 의존성에서 제외하고 필요시 깊은 비교 사용.
| value={additionalPriceInput.displayValue} | ||
| onChange={additionalPriceInput.handleChange} | ||
| error={!!errors?.additionalPrice && additionalPrice !== null} | ||
| errorMessage={errors?.additionalPrice?.message} |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Optional: additionalPrice 에러 표시 조건이 어색
additionalPrice !== null일 때만 에러를 보여주면, 사용자가 비워두면 검증 메시지가 노출되지 않습니다. 스키마에서 null이 허용되지 않는다면 조건을 제거하세요.
| <div className="flex items-center justify-end gap-16"> | ||
| <button className="text-gray-700" onClick={onDelete}> | ||
| <Trash2 size={20} /> | ||
| </button> | ||
| <button className="text-gray-700" onClick={onCopy}> | ||
| <Copy size={20} /> | ||
| </button> | ||
| </div> |
There was a problem hiding this comment.
<button> 기본 type이 submit이라 폼 제출 유발
폼 내부의 삭제/복사 버튼에 type이 없어 클릭 시 상위 form이 제출될 수 있습니다(브라우저 기본값이 submit). 의도치 않은 저장이 일어날 위험이 큽니다. type="button" 명시가 필요합니다.
🐛 제안 패치
- <button className="text-gray-700" onClick={onDelete}>
+ <button type="button" className="text-gray-700" onClick={onDelete}>
<Trash2 size={20} />
</button>
- <button className="text-gray-700" onClick={onCopy}>
+ <button type="button" className="text-gray-700" onClick={onCopy}>
<Copy size={20} />
</button>📝 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.
| <div className="flex items-center justify-end gap-16"> | |
| <button className="text-gray-700" onClick={onDelete}> | |
| <Trash2 size={20} /> | |
| </button> | |
| <button className="text-gray-700" onClick={onCopy}> | |
| <Copy size={20} /> | |
| </button> | |
| </div> | |
| <div className="flex items-center justify-end gap-16"> | |
| <button type="button" className="text-gray-700" onClick={onDelete}> | |
| <Trash2 size={20} /> | |
| </button> | |
| <button type="button" className="text-gray-700" onClick={onCopy}> | |
| <Copy size={20} /> | |
| </button> | |
| </div> |
| {productDetail ? ( | ||
| <div | ||
| className={cn( | ||
| // eslint-disable-next-line better-tailwindcss/no-unknown-classes | ||
| 'ql-editor leading-relaxed text-gray-700', | ||
| )} | ||
| dangerouslySetInnerHTML={{ __html: productDetail }} | ||
| /> | ||
| ) : ( | ||
| <p className="typo-body-14-r text-gray-400"> | ||
| 상세 설명을 등록해주세요 | ||
| </p> | ||
| )} |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP 'dompurify|DOMPurify|sanitize' --type=ts --type=tsx -C2Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 116
🏁 Script executed:
#!/bin/bash
# Search for DOMPurify, sanitize, dangerouslySetInnerHTML usage
rg -n 'dompurify|DOMPurify|sanitize' -t ts -C2Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 692
🏁 Script executed:
#!/bin/bash
# Check file type support in rg
rg --type-list | grep -i 'ts\|tsx'Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 780
🏁 Script executed:
#!/bin/bash
# Read the specific file to understand productDetail context
cat -n apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx | head -230 | tail -40Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1804
🏁 Script executed:
#!/bin/bash
# Check if DOMPurify is in package.json
find . -name 'package.json' -exec grep -l 'dompurify\|DOMPurify' {} \;Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 70
🏁 Script executed:
#!/bin/bash
# Search for all dangerouslySetInnerHTML usages
rg 'dangerouslySetInnerHTML' -t ts -B3 -A3Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 988
🏁 Script executed:
#!/bin/bash
# Find where productDetail comes from in the modal component
cat -n apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx | head -100Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 4212
🏁 Script executed:
#!/bin/bash
# Search for productDetail definition in the component
rg 'productDetail' -t ts -B5 -A2 apps/seller/src/features/products/create/create-preview/Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 4251
🏁 Script executed:
#!/bin/bash
# Check editor component to understand sanitization approach
cat -n packages/ui/src/editor/editor.tsx | head -80Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 2588
미리보기 HTML XSS 위험 — 런타임 sanitize 필수
productDetail은 Zustand 스토어에 저장된 원본 HTML로, Quill 에디터의 customSanitize(URL 프로토콜만 검증)는 표시 시점에 적용되지 않습니다. 악의적인 HTML이 그대로 렌더링될 수 있으므로, DOMPurify 추가 또는 스토어 저장 단계에서 전처리 필요합니다.
권장 해결안
- DOMPurify 추가:
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(productDetail) }} - 또는 스토어 저장 시 미리 정제
🧰 Tools
🪛 ast-grep (0.42.1)
[warning] 210-210: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
| import { useCreateHeaderSteps } from '../create-store' | ||
| import { useProductCreationStore } from '../create-store/product-creation.store' | ||
|
|
||
| export const useCreatePreviewPreviewHook = () => { |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
훅 네이밍 중복 (Optional)
useCreatePreviewPreviewHook — Preview가 두 번이고 접미사 Hook은 React 관례상 불필요해요. useCreatePreview 정도로 정리를 권장해요.
| const mainImageUrl = formData.mainImage | ||
| ? URL.createObjectURL(formData.mainImage) | ||
| : null | ||
| const extraImageUrls = (formData.extraImages ?? []).map((f) => | ||
| URL.createObjectURL(f), | ||
| ) | ||
| const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : [] |
There was a problem hiding this comment.
URL.createObjectURL 메모리 릭 (Critical)
watch()가 갱신될 때마다(키 입력 등) 매 렌더에서 새 ObjectURL을 만들고 revokeObjectURL 호출이 없어요. 미리보기 모달을 잠시만 사용해도 다수의 blob URL이 누적되어 메모리/Blob 핸들이 누수돼요.
대안: useMemo로 파일별 URL을 캐시하고 useEffect cleanup에서 revokeObjectURL을 호출하세요.
🩹 제안
-import { useFormContext } from 'react-hook-form'
+import { useEffect, useMemo } from 'react'
+import { useFormContext } from 'react-hook-form'
@@
- const mainImageUrl = formData.mainImage
- ? URL.createObjectURL(formData.mainImage)
- : null
- const extraImageUrls = (formData.extraImages ?? []).map((f) =>
- URL.createObjectURL(f),
- )
- const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : []
+ const allImageUrls = useMemo(() => {
+ const main = formData.mainImage ? [URL.createObjectURL(formData.mainImage)] : []
+ const extras = (formData.extraImages ?? []).map((f) => URL.createObjectURL(f))
+ return [...main, ...extras]
+ }, [formData.mainImage, formData.extraImages])
+
+ useEffect(() => {
+ return () => {
+ allImageUrls.forEach((url) => URL.revokeObjectURL(url))
+ }
+ }, [allImageUrls])📝 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.
| const mainImageUrl = formData.mainImage | |
| ? URL.createObjectURL(formData.mainImage) | |
| : null | |
| const extraImageUrls = (formData.extraImages ?? []).map((f) => | |
| URL.createObjectURL(f), | |
| ) | |
| const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : [] | |
| import { useEffect, useMemo } from 'react' | |
| import { useFormContext } from 'react-hook-form' | |
| // ... (other code) | |
| const allImageUrls = useMemo(() => { | |
| const main = formData.mainImage ? [URL.createObjectURL(formData.mainImage)] : [] | |
| const extras = (formData.extraImages ?? []).map((f) => URL.createObjectURL(f)) | |
| return [...main, ...extras] | |
| }, [formData.mainImage, formData.extraImages]) | |
| useEffect(() => { | |
| return () => { | |
| allImageUrls.forEach((url) => URL.revokeObjectURL(url)) | |
| } | |
| }, [allImageUrls]) |
| const unlock = () => set({ isScrolling: false }) | ||
| if ('onscrollend' in window) { | ||
| window.addEventListener('scrollend', unlock, { once: true }) | ||
| setTimeout(unlock, 1000) // fallback | ||
| } else { | ||
| setTimeout(unlock, 800) | ||
| } |
There was a problem hiding this comment.
scrollend fallback 타이머 정리 누락
scrollend가 발생해도 setTimeout(unlock, 1000)은 그대로 실행되어 isScrolling을 또 한 번 false로 덮어씁니다. 다른 스크롤이 그 사이 시작되면 의도치 않게 락이 풀릴 수 있어요. listener에서 timeoutId를 clear하도록 정리해 주세요.
♻️ 제안 패치
- const unlock = () => set({ isScrolling: false })
- if ('onscrollend' in window) {
- window.addEventListener('scrollend', unlock, { once: true })
- setTimeout(unlock, 1000) // fallback
- } else {
- setTimeout(unlock, 800)
- }
+ let timeoutId: ReturnType<typeof setTimeout>
+ const unlock = () => {
+ clearTimeout(timeoutId)
+ window.removeEventListener('scrollend', unlock)
+ set({ isScrolling: false })
+ }
+ if ('onscrollend' in window) {
+ window.addEventListener('scrollend', unlock, { once: true })
+ timeoutId = setTimeout(unlock, 1000)
+ } else {
+ timeoutId = setTimeout(unlock, 800)
+ }📝 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.
| const unlock = () => set({ isScrolling: false }) | |
| if ('onscrollend' in window) { | |
| window.addEventListener('scrollend', unlock, { once: true }) | |
| setTimeout(unlock, 1000) // fallback | |
| } else { | |
| setTimeout(unlock, 800) | |
| } | |
| let timeoutId: ReturnType<typeof setTimeout> | |
| const unlock = () => { | |
| clearTimeout(timeoutId) | |
| window.removeEventListener('scrollend', unlock) | |
| set({ isScrolling: false }) | |
| } | |
| if ('onscrollend' in window) { | |
| window.addEventListener('scrollend', unlock, { once: true }) | |
| timeoutId = setTimeout(unlock, 1000) | |
| } else { | |
| timeoutId = setTimeout(unlock, 800) | |
| } |
| getActiveTags: () => { | ||
| const { nutritionDataList } = get() | ||
| const allCategories = nutritionDataList.flatMap( | ||
| (d) => d.ingredientCategories, | ||
| ) | ||
|
|
||
| const isGlutenFree = allCategories.includes('glutenFree') | ||
| const isVegan = allCategories.includes('vegan') | ||
| const isHighProtein = nutritionDataList.some( | ||
| (d) => d.protein !== null && d.protein >= 11, | ||
| ) | ||
| const isLowFat = nutritionDataList.some( | ||
| (d) => d.fat !== null && d.fat < 3, | ||
| ) | ||
| const isLowSugar = nutritionDataList.some( | ||
| (d) => d.sugar !== null && d.sugar < 5, | ||
| ) | ||
|
|
||
| return { | ||
| [EssentialOptions[0].title]: isGlutenFree, | ||
| [EssentialOptions[1].title]: isVegan, | ||
| [EssentialOptions[2].title]: isHighProtein, | ||
| [EssentialOptions[3].title]: isLowFat, | ||
| [EssentialOptions[4].title]: isLowSugar, | ||
| [CategoryOptions[0].title]: isLowSugar && isLowFat, | ||
| [CategoryOptions[1].title]: isHighProtein, | ||
| [CategoryOptions[2].title]: isVegan && isGlutenFree, | ||
| } | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP -C3 'getActiveTags|useCreateHeaderStore|useCreateHeaderSteps' --type=ts --type=tsxRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 116
🏁 Script executed:
rg -nP -C5 'getActiveTags|useCreateHeaderStore|useCreateHeaderSteps' --type tsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 34711
getActiveTags 호출 시점의 상태로 고정되어 반응성 누락
useCreateHeaderSteps() 훅 내에서 activeTags: store.getActiveTags()를 직접 호출하므로, Zustand 구독이 수립되지 않습니다. 결과적으로 nutritionDataList 변경 후 칩의 활성 상태가 갱신되지 않습니다.
대안:
- 컴포넌트에서
nutritionDataList를 직접 구독한 뒤useMemo로 태그 파생 (권장) - 또는 store 외부에서 derived state 관리
There was a problem hiding this comment.
Actionable comments posted: 7
♻️ Duplicate comments (2)
apps/seller/src/features/products/create/create-form/create-form-mapper.ts (2)
32-40:⚠️ Potential issue | 🟠 Major다이어트 태그 로직 보강 필요
- L34-37:
hasNutrition === false여도 protein/sugar 잔존값으로highProteinTag/sugarFreeTag가 true가 될 수 있습니다.option.hasNutrition && option.protein != null && option.protein >= 11형태로 가드 필요.- L40:
ketogenicTag가 항상false로 하드코딩되어 있어 케토 표시가 불가합니다. 폼/기준이 미정이라면 TODO를 명시하거나 임시 비활성 사유를 주석으로 남겨주세요.
75-92: 🧹 Nitpick | 🔵 Trivial에디터 이미지 src 치환 안전성
정규식 기반
src치환은 속성 순서/따옴표 변형, 동일 파일명 다중 첨부 시 백엔드 키 충돌 위험이 있습니다. 고유 키(예:${index}_${file.name})로 치환하거나DOMParser로 안전 파싱하는 방향을 권장합니다.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ad1a7bf3-24d2-4d11-b4a4-8885ed80817a
📒 Files selected for processing (5)
apps/seller/src/entity/products/create/create-form/create-indivisual-form.type.tsapps/seller/src/entity/products/create/create-form/create.type.tsapps/seller/src/entity/products/create/create-info/product-discount-type.constants.tsapps/seller/src/features/products/create/create-form-info/create-info.schema.tsapps/seller/src/features/products/create/create-form/create-form-mapper.ts
| productionStartAt: string | ||
| price: number | ||
| discountType: 'AMOUNT' | 'RATE' | ||
| discountValue: number | ||
| deliveryCondition: string | ||
| deliveryCompany: string | ||
| deliveryFee: number | ||
| freeShippingConditions: number | ||
| content: string | ||
| products: ProductOptionRequest[] | ||
| productInfoNoticeRequest: Record<string, string> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP '\b(productionStartAt|productionStartTime|boardDetailRequest\.content)\b' -C2Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1599
🏁 Script executed:
# 백엔드 API 스키마나 DTO 정의 찾기
find . -type f \( -name "*.ts" -o -name "*.java" -o -name "*.dto.ts" \) | head -20Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1062
🏁 Script executed:
# 매퍼 전체 컨텍스트 확인 - multipartData.append 패턴 분석
rg -A1 -B1 "multipartData\.append" apps/seller/src/features/products/create/create-form/create-form-mapper.ts | head -50Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1426
🏁 Script executed:
# 백엔드 타입 정의가 있는지 확인
find . -path "*/backend/*" -o -path "*/api/*" | grep -E "(dto|entity|type|schema)" | head -20Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 70
🏁 Script executed:
# create.type.ts 전체 파일 확인하여 필드명 정확히 파악
cat apps/seller/src/entity/products/create/create-form/create.type.tsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1394
타입 정의가 실제 API 필드명과 불일치 - 매퍼와 타입 계약이 어긋남
create.type.ts는 productionStartAt과 content로 정의했지만, create-form-mapper.ts는 백엔드 API에 productionStartTime(L127)과 boardDetailRequest.content(L143)로 전송합니다. 타입 파일이 실제 API 계약을 반영하지 않으면 개발자가 잘못된 필드명을 사용하게 되어 API 요청이 매핑되지 않을 위험이 있습니다. 백엔드 DTO 정의를 확인 후 타입 파일을 백엔드 API에 맞춰 수정하세요.
|
|
||
| export interface ProductOptionRequest { | ||
| title: string | ||
| category: string |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Optional 정리
- L21
category: string→'BREAD' | 'BAGEL' | 'CAKE' | 'JAM' | 'COOKIE' | 'GRANOLA' | 'ETC'리터럴 유니온이 mapper의CATEGORY_MAP값과 정합성을 가집니다. - L16
productInfoNoticeRequest의 키도ProductInfoNoticeKey로 좁히면 안전합니다. - L52-59
StoreInfo의email/phoneNumber는 백엔드 응답이 nullable일 수 있으니 확인 필요합니다.
| nutritionInfo: { | ||
| totalWeight: number | ||
| servingSize: number | ||
| carbohydrates: number | ||
| sugars: number | ||
| protein: number | ||
| fat: number | ||
| calories: number | ||
| } | null |
There was a problem hiding this comment.
nutritionInfo에 sodium 누락
ProductOptionFormInput은 sodium 필드를 가지지만 API 타입의 nutritionInfo에는 없습니다. 결과적으로 사용자가 입력한 나트륨 값이 제출에서 누락됩니다. 백엔드 스펙과 맞춰 sodium: number를 추가하거나(추가 시 mapper에도 반영), 폼에서 sodium을 제거해야 합니다.
| ...(option.hasNutrition | ||
| ? { | ||
| [`products[${index}].nutritionInfo.totalWeight`]: | ||
| option.totalWeight ?? 0, | ||
| [`products[${index}].nutritionInfo.servingSize`]: | ||
| option.totalWeight ?? 0, | ||
| [`products[${index}].nutritionInfo.carbohydrates`]: | ||
| option.carbohydrate ?? 0, | ||
| [`products[${index}].nutritionInfo.sugars`]: option.sugar ?? 0, | ||
| [`products[${index}].nutritionInfo.protein`]: option.protein ?? 0, | ||
| [`products[${index}].nutritionInfo.fat`]: option.fat ?? 0, | ||
| [`products[${index}].nutritionInfo.calories`]: option.calories ?? 0, | ||
| } | ||
| : {}), |
There was a problem hiding this comment.
servingSize에 totalWeight을 재사용 — 영양 정보 오류
L54-55에서 servingSize를 option.totalWeight로 채우고 있어 1회 제공량이 총 중량과 동일해집니다. 표시·계산이 모두 왜곡됩니다. 폼에 별도 servingSize 필드를 추가하거나, 백엔드와 합의해 의도한 매핑(예: 단일 제공일 경우 동일 처리)을 코드 주석으로 명시해 주세요.
또한 ProductOptionFormInput.sodium 값이 어디에도 append되지 않아 사용자가 입력한 나트륨이 누락됩니다. nutritionInfo.sodium 추가 필요합니다.
| const toProductionTimeFormat = (time: string): string => { | ||
| // "06:00~07:00" or "06:00" 둘 다 처리 | ||
| const start = time.split('~')[0].trim() // "06:00" | ||
| const [hour] = start.split(':') // "06" | ||
| const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07" | ||
| return `T_${hour}_${nextHour}` // "T_06_07" | ||
| } |
There was a problem hiding this comment.
toProductionTimeFormat 자정 wrap-around 버그
"23:00" 입력 시 nextHour가 "24"가 되어 T_23_24라는 잘못된 토큰이 생성됩니다. 23시 슬롯이 백엔드 검증에서 거절될 위험이 있습니다.
- const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07"
+ const nextHour = String((Number(hour) + 1) % 24).padStart(2, '0')또한 잘못된 형식 입력에 대한 가드(NaN 체크)도 추가 권장합니다.
📝 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.
| const toProductionTimeFormat = (time: string): string => { | |
| // "06:00~07:00" or "06:00" 둘 다 처리 | |
| const start = time.split('~')[0].trim() // "06:00" | |
| const [hour] = start.split(':') // "06" | |
| const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07" | |
| return `T_${hour}_${nextHour}` // "T_06_07" | |
| } | |
| const toProductionTimeFormat = (time: string): string => { | |
| // "06:00~07:00" or "06:00" 둘 다 처리 | |
| const start = time.split('~')[0].trim() // "06:00" | |
| const [hour] = start.split(':') // "06" | |
| const nextHour = String((Number(hour) + 1) % 24).padStart(2, '0') | |
| return `T_${hour}_${nextHour}` // "T_06_07" | |
| } |
| multipartData.append('price', String(form.price ?? 0)) | ||
| multipartData.append( | ||
| 'discountType', | ||
| form.discountType === 'AMOUNT' ? 'AMOUNT' : 'RATE', | ||
| ) | ||
| multipartData.append('discountValue', String(form.discountAmount ?? 0)) | ||
| multipartData.append('deliveryCondition', form.deliveryTerms) | ||
| multipartData.append('deliveryCompany', form.deliveryCompany) | ||
| multipartData.append('deliveryFee', String(form.deliveryFee ?? 0)) | ||
| multipartData.append( | ||
| 'freeShippingConditions', | ||
| String(form.deliveryMinFee ?? 0), | ||
| ) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Optional 정리
- L130/135/138/141:
price/discountAmount/deliveryFee/deliveryMinFee가null일 때 무음으로0을 보냅니다. 제출 전 폼 유효성에서 차단되는지 확인하거나, 여기서도 명시적 가드를 두면 안전합니다. - L131-134:
form.discountType === 'AMOUNT' ? 'AMOUNT' : 'RATE'삼항이 사실상 항등이라String(form.discountType)만으로 충분합니다(타입이 이미'AMOUNT' | 'RATE'). - L122:
storeId가 0/NaN인지에 대한 가드 권장(임시 하드코딩 1 fallback과의 상호작용 확인).
| // 옵션들 | ||
| form.options.forEach((option, index) => { | ||
| const optionFields = mapOption(option, index) | ||
| Object.entries(optionFields).forEach(([key, value]) => { | ||
| multipartData.append(key, String(value)) | ||
| }) | ||
| }) | ||
|
|
||
| // 상품 정보 고시 | ||
| Object.entries(form.productInfoNotice).forEach(([key, value]) => { | ||
| multipartData.append(`productInfoNoticeRequest.${key}`, value) | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP 'productInfoNoticeMode|productInfoNoticeRequest' -C2Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 7422
🏁 Script executed:
# 백엔드 API 정의 및 productInfoNoticeMode 사용 패턴 확인
rg -l 'productInfoNoticeMode|productInfoNoticeRequest' --type ts --type tsx | head -20Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 116
🏁 Script executed:
# 더 넓은 범위에서 Request/Dto 타입 및 백엔드 문서 확인
rg -nP 'productInfoNoticeRequestMode|noticeMode.*Request' -iRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 70
🏁 Script executed:
# Mapper 함수가 어디서 호출되는지, 어떤 엔드포인트로 전송되는지 확인
rg -nP 'createFormMapper|multipartData' -B 3 -A 3 | head -50Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 5470
🏁 Script executed:
# 백엔드 API 호출 및 요청 페이로드 구조 확인
rg -nP 'POST|PUT|product.*create|api.*product' --type ts -i | grep -E '(api|endpoint|url|fetch|axios)' -i | head -30Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 2445
🏁 Script executed:
# create.api.ts 전체 코드 확인 - 어떤 FormData가 전송되는지 보기
cat -n apps/seller/src/entity/products/create/create-form/create.api.tsRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 777
🏁 Script executed:
# 테스트 파일에서 productInfoNotice 관련 예상 페이로드 확인
rg -nP 'productInfoNotice' --type ts | grep -E '(test|spec|mock)' -iRepository: eco-dessert-platform/dessert-front-dashboard
Length of output: 70
🏁 Script executed:
# API 응답 타입이나 요청 관련 주석/문서 확인
rg -nP 'productInfoNotice.*Request|@RequestParam.*mode|Mode.*Request' -B 2 -A 2Repository: eco-dessert-platform/dessert-front-dashboard
Length of output: 1094
productInfoNoticeMode FormData 직렬화 누락
폼 상태에 정의된 productInfoNoticeMode(default/manual)가 create-form-mapper.ts의 FormData 직렬화 로직(L154-156)에서 누락됩니다. productInfoNotice 값만 productInfoNoticeRequest.${key} 패턴으로 append되고, mode는 전송되지 않습니다.
UI에서 mode를 활용해 필드 동작을 제어하고 있으므로, 백엔드에서 mode를 필요로 한다면 요청이 불완전해집니다. 의도된 생략이라면 주석으로 명시, 아니면 productInfoNoticeRequestMode.${key} 패턴으로 추가 필요합니다.
|
|
PR 규모에 대한 피드백입니다. 현재 이 PR은 +2,575 -754, 총 3,300줄이 넘는 변경을 포함하고 있습니다. 리뷰어가 집중력을 유지하며 꼼꼼히 볼 수 있는 한계는 약 400줄로, 이 규모에서는 중요한 설계 문제나 버그를 놓칠 가능성이 높아집니다. FSD 아키텍처를 사용하는 경우, 브랜치도 레이어 경계를 따라 나누는 것을 권장합니다. 레이어별로 분리하면:
이 PR은 Draft 상태로 전환한 뒤, 위와 같이 레이어별로 브랜치를 나눠 새로 PR을 올려주시면 좋겠습니다. entity 관련 PR을 먼저 올려주시면 1시간 이내로 리뷰 가능합니다. |
| import { queryOptions } from '@tanstack/react-query' | ||
|
|
||
| import { getMyStore } from './create.api' | ||
|
|
||
| export const productKeys = { | ||
| all: ['products'] as const, | ||
| myStore: () => [...productKeys.all, 'myStore'] as const, | ||
| } | ||
|
|
||
| export const productQueries = { | ||
| myStore: () => | ||
| queryOptions({ | ||
| queryKey: productKeys.myStore(), | ||
| queryFn: getMyStore, | ||
| }), | ||
| } |
There was a problem hiding this comment.
productKeys와 productQueries를 별도 객체로 분리하는 방식보다, queryOptions를 활용해 queryKey와 queryFn을 하나의 객체에서 관리하는 Query Factory 패턴을 권장합니다.
export const productQueries = {
all: () => ['products'] as const,
myStore: () =>
queryOptions({
queryKey: [...productQueries.all(), 'myStore'],
queryFn: getMyStore,
}),
}queryKey와 queryFn은 본질적으로 밀접하게 연결되어 있어, 분리하면 키 변경 시 두 곳을 동시에 수정해야 하는 유지보수 부담이 생깁니다. 통합하면 관련 로직이 한 곳에 응집되고, useQuery와 queryClient.prefetchQuery 등에서 동일한 옵션을 자동으로 공유할 수 있습니다.
이슈 번호
#173
작업 내용
공통 컴포넌트 변경 사항
테스트 가이드
작업 내용이 많기때문에 직접 브랜치 ( feat/products-create-page ) 에 접속해 테스트, 리뷰 해주시는 것이 수월할 것 같습니다.
하단 부분에 중점을 두고 테스트 해 주시면 감사하겠습니다.
기능 동작
상품 등록 폼의 복사, 상품 추가, 삭제 기능이 올바르게 작동하는지 확인 부탁 드립니다.
모든 필수 입력 사항을 채운 후 저장하기(form 제출)이 정상적으로 이루어지는지 확인
디자인
FSD 구조
그 외
To reviewers
FSD 구조 관련
storeId 하드코딩
Layout 임시 수정