feat: 새 가게 등록 UI 구현#31
Conversation
📝 WalkthroughWalkthrough3단계 스토어 등록 UI와 라우팅을 추가했습니다. 단계는 사업자 인증, 매장 정보 입력, 메뉴 등록이며, 각 스텝은 react-hook-form과 Zod로 유효성 검증되고 StoreRegistrationPage가 단계 전환·데이터 병합·완료 흐름을 조율합니다. Changes
Sequence Diagram(s)sequenceDiagram
actor User as 사용자
participant Router as 브라우저/라우터
participant Page as StoreRegistrationPage
participant Step1 as StepBusinessAuth
participant Step2 as StepStoreInfo
participant Step3 as StepMenuRegistration
participant Schema as Zod_Schema
User->>Router: /mypage/store/register 접근
Router->>Page: 라우팅 렌더
Page->>Step1: 렌더 (onComplete 전달)
User->>Step1: 사업자번호 입력/제출
Step1->>Schema: 사업자번호 검증
Schema-->>Step1: 검증결과
Step1-->>Page: onComplete({ businessNumber, isVerified })
User->>Page: 다음 클릭
Page->>Step2: 렌더 (defaultValues, onChange)
User->>Step2: 매장정보 입력
Step2->>Schema: 실시간 검증
Step2-->>Page: onChange(isValid, data)
User->>Page: 다음 클릭
Page->>Step3: 렌더 (onChange)
User->>Step3: 메뉴 추가 및 이미지 업로드
Step3->>Schema: 메뉴 검증
Step3-->>Page: onChange(isValid, menuData)
User->>Page: 등록 완료 클릭
Page->>Page: 데이터 병합 및 price 숫자 변환
Page->>Router: 완료 후 /mypage/store 로 리다이렉트
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 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
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/components/store-registration/MenuItemInput.tsx`:
- Around line 32-40: handleImageChange creates object URLs with
URL.createObjectURL but never revokes them, causing memory leaks; modify
handleImageChange to revoke any previous preview URL (the state variable set by
setPreviewUrl) before creating a new one, and add a useEffect cleanup that
revokes the current preview URL on component unmount (or when previewUrl
changes) to ensure URL.revokeObjectURL is always called; reference the
handleImageChange function, the previewUrl state, setPreviewUrl, and the
component's useEffect cleanup for where to add the revocation logic.
In `@src/pages/myPage/StoreRegistrationPage.tsx`:
- Around line 63-66: The current mapping in StoreRegistrationPage.tsx uses
Number(menu.price) which can yield NaN; change the menus mapping (where menus:
(step3Data.menus || []).map(...)) to defensively parse and validate menu.price:
parseFloat(menu.price as string) into a variable (e.g., parsedPrice), then check
Number.isFinite(parsedPrice) (or !isNaN(parsedPrice)) and either set price to
parsedPrice or to a safe fallback (null/undefined) or reject the submission by
triggering validation; ensure the final payload sent to the API contains only
valid numeric prices or the form surface shows validation errors for invalid
inputs.
🧹 Nitpick comments (11)
src/components/store-registration/StoreInfo.schema.ts (1)
14-15: Consider adding cross-field validation for business hours.The schema validates
openTimeandcloseTimeindependently but doesn't ensure that close time is after open time. This could allow invalid business hours (e.g., open at 22:00, close at 08:00 on the same day).You may want to add a
.refine()check if same-day validation is needed, or document that overnight hours are intentionally supported.Example refinement if same-day validation is desired
export const StoreInfoSchema = z.object({ storeName: z.string().min(1, { message: "가게 이름을 입력하세요." }), category: z.string().min(1, { message: "음식 종류를 선택하세요." }), address: z.string().min(1, { message: "주소를 입력하세요." }), detailAddress: z.string().optional(), phone: z .string() .min(1, { message: "전화번호를 입력하세요." }) .regex(/^\d{2,3}-\d{3,4}-\d{4}$/, { message: "올바른 전화번호 형식이 아닙니다.", }), openTime: z.string().min(1, { message: "시작 시간을 선택하세요." }), closeTime: z.string().min(1, { message: "종료 시간을 선택하세요." }), description: z.string().optional(), -}); +}).refine((data) => data.openTime < data.closeTime, { + message: "종료 시간은 시작 시간 이후여야 합니다.", + path: ["closeTime"], +});src/components/store-registration/Menu.schema.ts (1)
13-13: Consider stricter typing for the image field.Using
z.any()bypasses type validation entirely. Since this field expects aFileobject from the upload input, consider using a more specific validator for better type safety and runtime validation.Stricter image validation options
- image: z.any().optional(), + image: z.union([z.instanceof(File), z.null()]).optional(),Or if you need to support serialized data as well:
- image: z.any().optional(), + image: z.custom<File | null>((val) => val === null || val instanceof File).optional(),src/components/store-registration/MenuItemInput.tsx (1)
76-82: Consider adding client-side file validation.The UI displays file constraints ("최대 용량: 5MB", "형식: JPG, PNG") but they're not enforced. Adding validation would provide immediate feedback before upload.
Example validation in handleImageChange
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) { + const MAX_SIZE = 5 * 1024 * 1024; // 5MB + const ALLOWED_TYPES = ['image/jpeg', 'image/png']; + + if (file.size > MAX_SIZE) { + alert('파일 크기는 5MB 이하여야 합니다.'); + return; + } + if (!ALLOWED_TYPES.includes(file.type)) { + alert('JPG 또는 PNG 형식만 지원합니다.'); + return; + } + const url = URL.createObjectURL(file); setPreviewUrl(url); setValue(`menus.${index}.image`, file); } };src/components/store-registration/StepMenuRegistration.tsx (1)
66-72: Addtype="button"to prevent accidental form submission.The "메뉴 추가" button doesn't specify
type="button". If this component is rendered inside a<form>element (now or in the future), clicking it could trigger form submission since the default button type is"submit".Proposed fix
<button + type="button" onClick={() => append({ menuName: "", price: "", description: "" })} className="w-full py-3 border-2 border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-700 flex items-center justify-center gap-2 cursor-pointer" >src/components/store-registration/StepStoreInfo.tsx (2)
37-40: Consider usingwatch()with a callback oruseWatchfor better performance.Using
JSON.stringify(values)in the dependency array serializes the entire form on every render to detect changes. This can be inefficient for larger forms.Consider using
useWatchwith a subscription callback or restructuring to avoid the serialization:♻️ Suggested refactor using useWatch
-import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; ... - //값이 변할 때마다 부모에게 실시간 보고 - const values = watch(); - - useEffect(() => { - onChange(isValid, values as StoreInfoFormValues); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isValid, JSON.stringify(values), onChange]); + // Subscribe to form changes and report to parent + useWatch({ + control, + onChange: ({ values }) => { + onChange(isValid, values as StoreInfoFormValues); + }, + }); + + useEffect(() => { + onChange(isValid, watch() as StoreInfoFormValues); + }, [isValid]);Note: You'll need to extract
controlfromuseForm.
109-114: Placeholder button noted — ensure address search is tracked for completion.The "주소 검색" button is currently a placeholder with no functionality. Per the linked issue, API integration is still pending.
Would you like me to open an issue to track the address search integration?
src/App.tsx (1)
45-49: Route works but consider nesting under/mypage/*for consistency.While React Router v6's scoring algorithm ensures
/mypage/store/registermatches before/mypage/*, having this route as a sibling creates organizational inconsistency. Also, there's a minor formatting issue (extra space before{).♻️ Suggested formatting fix
- { + { path: "/mypage/store/register", //가게 등록 경로 - element:<StoreRegistrationPage />, + element: <StoreRegistrationPage />, errorElement: <NotFound />, - } + },src/components/store-registration/StepBusinessAuth.tsx (2)
7-12: Add custom error messages to the schema for better UX.The schema will show generic error messages like "Invalid" when validation fails. Adding descriptive messages helps users understand what's wrong.
♻️ Suggested improvement
const schema = z.object({ businessNumber: z .string() - .regex(/^[0-9]+$/) - .length(10), + .regex(/^[0-9]+$/, { message: "숫자만 입력해주세요." }) + .length(10, { message: "사업자등록번호는 10자리입니다." }), });
43-57: Mock API always succeeds — consider adding error handling for real integration.The simulated API call always returns success. When integrating with the real API, ensure proper error handling is added (e.g., invalid business number, network failure).
Also, Line 49 has a
console.logthat should be removed before production.🧹 Remove console.log
- console.log("인증번호:", data.businessNumber);src/components/store-registration/RegistrationStepper.tsx (1)
38-43: Consider using Tailwind's arbitrary value syntax instead of inline style.For consistency with the rest of the styling, the
minWidthinline style can be replaced with Tailwind's arbitrary value syntax.♻️ Suggested change
<div className={`hidden sm:block flex-1 h-1 mx-4 transition-colors ${ currentStep > step.number ? "bg-blue-500" : "bg-gray-200" - }`} - style={{ minWidth: "80px" }} + } min-w-[80px]`} />src/pages/myPage/StoreRegistrationPage.tsx (1)
69-72: Removeconsole.logand replacealert()with a proper notification.
console.logshould be removed for production, andalert()provides a poor user experience. Consider using a toast notification component for success feedback.🧹 Suggested cleanup
- console.log("최종 데이터:", finalPayload); //API 호출 - alert("가게 등록이 완료되었습니다!"); + // TODO: Replace with toast notification + // toast.success("가게 등록이 완료되었습니다!");
a70557c to
bf701ce
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/components/store-registration/StepStoreInfo.tsx`:
- Around line 51-93: The form in StepStoreInfo (component StepStoreInfo.tsx)
lacks proper label/input associations and an onSubmit handler: add unique id
attributes for the storeName input and category select (e.g., id="storeName" and
id="category") and set the corresponding Label components' htmlFor to those ids
so screenreaders can associate labels with fields (references:
register("storeName"), register("category"), errors, touchedFields); also add an
onSubmit handler on the <form> that prevents default and delegates to the parent
submit callback or React Hook Form's handleSubmit to avoid accidental Enter-key
submissions and to centralize validation handling. Ensure any aria-required or
aria-invalid attributes reflect errors for accessibility.
🧹 Nitpick comments (5)
src/components/store-registration/RegistrationNavigation.tsx (1)
22-43: 접근성(a11y) 개선이 필요해요!코딩 가이드라인에 따라 접근성 체크를 해봤는데, 몇 가지 개선점이 있어요:
type="button"누락: 폼 내부에서 사용될 경우, 버튼이 기본적으로type="submit"으로 동작해서 의도치 않은 폼 제출이 발생할 수 있어요.아이콘 접근성: 장식용 아이콘에
aria-hidden="true"추가하면 스크린 리더 사용자 경험이 좋아져요.♻️ 접근성 개선 제안
<button + type="button" onClick={onPrev} disabled={currentStep === 1} className="flex items-center gap-2 px-6 py-4 sm:py-3 text-gray-500 border border-gray-500 rounded-lg hover:text-gray-700 hover:border-gray-700 transition-colors disabled:text-gray-400 disabled:border-gray-300 disabled:cursor-not-allowed cursor-pointer" > - <ChevronLeft className="size-5" /> + <ChevronLeft className="size-5" aria-hidden="true" /> 이전 </button> <button + type="button" onClick={onNext} disabled={isNextDisabled} className="flex items-center gap-2 px-6 py-4 sm:py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed cursor-pointer" > {isLastStep ? ( <>등록 완료</> ) : ( <> 다음 - <ChevronRight className="size-5" /> + <ChevronRight className="size-5" aria-hidden="true" /> </> )} </button>As per coding guidelines: "컴포넌트는 단일 책임, props 타입/네이밍 명확히, 접근성(aria) 체크."
src/components/store-registration/StepStoreInfo.tsx (3)
37-40:onChange콜백 안정성 개선을 권장해요.현재
JSON.stringify(values)를 의존성 배열에 사용하고 있는데, 이 패턴은 동작하지만 몇 가지 주의가 필요해요:
- 부모 컴포넌트에서
onChange를useCallback으로 메모이제이션하지 않으면 매 렌더링마다 새 참조가 생성되어 불필요한 effect 실행이 발생할 수 있어요.eslint-disable주석이 잠재적 이슈를 숨길 수 있어요.♻️ 개선 제안
useMemo로 직렬화된 값을 캐싱하거나, 부모에서onChange를 반드시 메모이제이션하도록 문서화하는 것을 권장해요:+ const serializedValues = JSON.stringify(values); + useEffect(() => { onChange(isValid, values as StoreInfoFormValues); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isValid, JSON.stringify(values), onChange]); + }, [isValid, serializedValues, onChange]);또는 부모 컴포넌트에서:
const handleStoreInfoChange = useCallback((isValid: boolean, data: StoreInfoFormValues) => { // ... }, []);
145-177: 영업시간 유효성 검사 및 반응형 레이아웃 확인 필요.두 가지 개선점이 있어요:
시간 유효성 검사:
closeTime이openTime보다 이른 시간이어도 유효성 검사를 통과해요. 스키마에서 cross-field validation을 추가하면 좋겠어요.반응형 그리드 불일치: Line 52에서는
grid-cols-1 md:grid-cols-2를 사용했는데, 여기서는grid-cols-2만 사용해서 모바일에서 레이아웃이 깨질 수 있어요.♻️ 개선 제안
레이아웃 일관성:
- <div className="grid grid-cols-2 gap-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4">시간 유효성 검사 (StoreInfo.schema.ts에 추가):
export const StoreInfoSchema = z.object({ // ... 기존 필드들 }).refine( (data) => !data.openTime || !data.closeTime || data.openTime < data.closeTime, { message: "종료 시간은 시작 시간보다 늦어야 합니다.", path: ["closeTime"], } );
109-114: 주소 검색 버튼 구현 예정 확인.이슈
#30에서API 연동이 pending 상태로 되어 있어서 현재 버튼에 기능이 없는 것으로 보여요. 추후 구현 시 누락되지 않도록 TODO 주석이나 비활성화 상태를 추가하면 좋겠어요.💡 선택적 개선
<button type="button" + disabled // TODO: 주소 검색 API 연동 후 활성화 className="px-6 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 whitespace-nowrap cursor-pointer" > 주소 검색 </button>src/components/store-registration/MenuItemInput.tsx (1)
140-145: 가격 입력은 숫자 키패드 유도가 있으면 좋아요.
inputMode="numeric"와pattern을 추가하면 모바일 UX가 개선됩니다.💡 개선 예시
<input {...register(`menus.${index}.price`)} type="text" + inputMode="numeric" + pattern="\d*" placeholder="30000" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@src/components/store-registration/RegistrationNavigation.tsx`:
- Around line 20-43: The two buttons in RegistrationNavigation should be
explicit non-submit controls and accessible: add type="button" to the previous
and next buttons (the elements using onPrev and onNext) to prevent accidental
form submission, and add descriptive aria-label attributes (e.g., "이전 단계로 이동"
for the onPrev button and "다음 단계로 이동" or "등록 완료" for the onNext button depending
on isLastStep) so screen readers clearly convey their actions; update the JSX
around the buttons that render ChevronLeft/ChevronRight and depend on
currentStep, isNextDisabled and isLastStep accordingly.
In `@src/components/store-registration/StepBusinessAuth.tsx`:
- Around line 43-52: Remove the sensitive console logging of the business
registration number in the onSubmit handler of StepBusinessAuth; specifically
delete or guard the line using console.log("인증번호:", data.businessNumber) inside
the onSubmit async function so that the businessNumber is not written to the
browser console in production (if you need logs for local debugging, wrap them
behind a debug flag or environment check such as process.env.NODE_ENV !==
'production' or a dedicated DEBUG flag).
- Around line 71-90: The label and input in StepBusinessAuth.tsx are not
explicitly connected and the input lacks ARIA error state; add an id (e.g.,
id="businessNumber") to the input that matches the label's htmlFor, and set
aria-invalid based on the form error for register("businessNumber") (e.g.,
formState.errors.businessNumber != null) and optionally aria-describedby
pointing to an error message element so screen readers can announce validation
state; ensure the onChange logic that references defaultValues.isVerified and
onComplete remains unchanged.
🧹 Nitpick comments (3)
src/components/store-registration/RegistrationStepper.tsx (1)
15-45: 접근성(aria) 보강이 필요해요.현재 단계 표시가 시각 정보에 치우쳐 있어 스크린리더가 현재 단계를 파악하기 어렵습니다.
nav/ol+aria-current="step"+ 아이콘aria-hidden/sr-only 텍스트로 보완해주세요.♿ 제안 수정
- return ( - <div className="bg-white border-b border-gray-200"> + return ( + <nav className="bg-white border-b border-gray-200" aria-label="등록 단계"> <div className="max-w-7xl mx-auto px-6 lg:px-8 py-8"> - <div className="flex items-start justify-between max-w-2xl mx-auto"> + <ol className="flex items-start justify-between max-w-2xl mx-auto"> {steps.map((step, index) => ( - <div key={step.number} className={`flex items-start ${index !== steps.length - 1 ? "flex-1" : ""}`}> + <li + key={step.number} + aria-current={currentStep === step.number ? "step" : undefined} + className={`flex items-start ${index !== steps.length - 1 ? "flex-1" : ""}`} + > <div className="flex flex-col items-center"> <div className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${ currentStep >= step.number ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-600" }`} > {currentStep > step.number ? ( - <Check className="size-6" /> + <> + <Check className="size-6" aria-hidden="true" /> + <span className="sr-only">완료</span> + </> ) : ( <span className="font-medium">{step.number}</span> )} </div> <span className="text-xs sm:text-sm text-gray-600 mt-2 break-keep text-center">{step.label}</span> </div> {index !== steps.length - 1 && ( <div className={`hidden sm:block flex-1 h-1 mx-8 mt-[24px] transition-colors ${ currentStep > step.number ? "bg-blue-500" : "bg-gray-200" }`} style={{ minWidth: "80px" }} /> )} - </div> + </li> ))} - </div> + </ol> </div> - </div> + </nav> );As per coding guidelines:
src/components/**: 컴포넌트는 단일 책임, props 타입/네이밍 명확히, 접근성(aria) 체크.src/pages/myPage/StoreRegistrationPage.tsx (2)
62-79:handleNext에서 유효성 가드/중복 제출 방지 추가를 권장해요.버튼이 disabled여도 다른 경로로 호출될 수 있으니, 핸들러 자체에서 한 번 더 막아두는 편이 안전합니다.
🔒 제안 수정
- const handleNext = () => { + const handleNext = () => { + if (isNextDisabled) return; if (currentStep < TOTAL_STEPS) { setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3); } else { const finalPayload = { ...step1Data, ...step2Data, menus: (step3Data.menus || []).map((menu) => ({ ...menu, price: Number(menu.price) || 0, //문자열 가격을 숫자로 변환, 실패 시 0 })), };
122-128: 아이콘 버튼에 접근성 레이블을 추가해주세요.닫기 버튼이 아이콘만 있어 보조기기에서 의미를 알기 어렵습니다.
♿ 제안 수정
<button type="button" onClick={() => setIsExitModalOpen(true)} + aria-label="가게 등록 닫기" className="text-gray-500 p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" > - <X className="size-6" /> + <X className="size-6" aria-hidden="true" /> </button>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/components/store-registration/StepBusinessAuth.tsx`:
- Around line 4-56: The async onSubmit in StepBusinessAuth can call setIsLoading
and onComplete after the component unmounts; wrap the async work in a
try/finally and add a mounted guard (useRef flag set in useEffect) so you only
call setIsLoading(false) and onComplete(...) when mounted; update the onSubmit
function to check the mounted flag before calling setIsLoading/onComplete. Also
wire accessibility and mobile UX: add inputMode="numeric" to the business number
input registered via register("businessNumber"), set
aria-describedby="businessNumber-error" on that input, and give the error
message element id="businessNumber-error" so errors are explicitly associated
with the field.
🧹 Nitpick comments (1)
src/components/store-registration/StepBusinessAuth.tsx (1)
74-90: 숫자 입력 UX 개선을 위해inputMode추가를 پیشنهاد드려요.사업자등록번호는 숫자만 입력하므로 모바일에서 숫자 키패드를 유도하면 입력 경험이 좋아집니다.
🛠️ 제안 수정
<input id="businessNumber" + inputMode="numeric" + pattern="\d*" {...register("businessNumber", { onChange: (e) => { if (defaultValues.isVerified) { onComplete({ businessNumber: e.target.value, isVerified: false, }); } }, })} type="text"모바일 환경에서 숫자 키패드가 표시되는지 한 번 확인해 주세요.
💡 개요
새 가게 등록 UI 구현
🔢 관련 이슈 링크
💻 작업내용
📌 변경사항PR
🤔 추가 논의하고 싶은 내용
✅ 체크리스트
Summary by CodeRabbit
New Features
Chores
✏️ Tip: You can customize this high-level summary in your review settings.