-
Notifications
You must be signed in to change notification settings - Fork 1
main 최신화 #329
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
Conversation
…into fix/jobdetailui-298
[✨FEATURE] 홈화면 UI pivot
[💄STYLE] 나의 할일 ui 수정
[✨FEATURE] 직업상세 ui 변경
[✨FEATURE] 커뮤니티 UI
[✨FEATURE] Amplitude 이벤트 추적 기능 추가 및 관련 코드 수정
[✨FEATURE] 직업상세 ui 변경
홈 화면 슬라이더 연결
[✨FEATURE] 홈 api 연결
…into feature/floatinghot-325
[✨FEATURE] 플로팅 인기 api 연결
[✨FEATURE] 커뮤니티 api 왼쪽
…into feature/community-303
[✨FEATURE] 커뮤니티 연결
Walkthrough커뮤니티 페이지(UI/데이터 연결)와 직업 상세 페이지(UI/탭/추천 할 일)가 대폭 개편되었고, 홈 배너를 슬라이더 기반으로 교체했습니다. 플로팅 인기/커뮤니티 인기/커뮤니티 투두 조회·추가·삭제 훅이 추가되었으며, 여러 화면에 Amplitude 분석 이벤트가 도입되었습니다. 일부 공통 UI/레이아웃도 수정되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant U as User
participant CL as CommunityLeftSide
participant CR as CommunityRightSide
participant ST as useCommunityStore
participant Q1 as useGetCommunityQuery
participant Q2 as useGetHotPopularQuery
participant Q3 as useCommunityGetTodo
U->>CL: 페이지 진입
CL->>Q1: 직업 목록 조회
Q1-->>CL: 직업 데이터
CL->>ST: 선택 직업 설정
CL->>Q2: 선택 직업 기반 인기 투두 조회
Q2-->>CL: 인기 리스트
U->>CR: 필터/정렬 조작
CR->>ST: 선택 직업 읽기
CR->>Q3: 투두 페이지네이션 조회(page 0)
Q3-->>CR: 페이지 데이터
U->>CR: 스크롤 도달
CR->>Q3: 다음 페이지 조회
Q3-->>CR: 다음 페이지 데이터
sequenceDiagram
autonumber
participant U as User
participant FB as FloatingButton
participant FM as FloatingModal
participant AP as Amplitude(window)
participant FQ as useFloatingPopular
U->>FB: 플로팅 버튼 클릭
FB-->>AP: track('todo_create', {source_method:'floating', ...}) [if present]
FB->>FM: 모달 열기
FM->>FQ: 인기 데이터 조회(로그인/게스트 분기)
FQ-->>FM: 인기 데이터/로딩 상태
U->>FM: 투두 제출
FM-->>AP: track('todo_create', {...}) [if present]
FM->>FM: 폼 제출 처리
sequenceDiagram
autonumber
participant U as User
participant JS as JobSelect
participant JR as JobRecommendPage
participant AP as Amplitude(window)
participant GTM as TagManager
U->>JS: 직업 첫 저장
JS-->>GTM: event('job_select')
JS-->>AP: track('job_select', {...}) [if present]
U->>JS: 직업 추천 받기 클릭
JS-->>AP: track('job_select'|'job_onboarding_start', {...}) [if present]
JS->>JR: /onboard 이동
U->>JR: 결과에서 직업 선택
JR-->>AP: track('job_onboarding_select', {...}) [if present]
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Assessment against linked issues
Assessment against linked issues: Out-of-scope changes
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
Status, Documentation and Community
|
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.
Actionable comments posted: 31
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/pages/login/LoginPage.tsx (1)
10-27: 회원가입 링크: preventDefault 제거 및 Link 사용으로 네이티브 동작 보존현재 a 태그에서 preventDefault + 수동 navigate로 새 탭 열기/컨텍스트 메뉴 등 기본 동작이 깨집니다. react-router의 Link를 사용하고, 클릭 시 트래킹만 수행하도록 단순화하세요.
- const navigate = useNavigate(); + // const navigate = useNavigate(); // 불필요 - const handleSignupClick = (e: React.MouseEvent) => { - e.preventDefault(); + const handleSignupClick = () => { ReactTagManager.action({ event: 'signup_start', category: 'Signup', clickText: '회원가입', source_page: location.pathname, }); - setTimeout(() => { - navigate('/signup'); - }, 200); - if (window.amplitude) { - window.amplitude.track('signup_start', { + if (window?.amplitude?.track) { + window.amplitude.track('signup_start', { source_page: location.pathname, timestamp: new Date().toISOString(), }); - console.log('Amplitude event sent: signup_start'); // 콘솔에서 전송 확인용 } };- <a - href="/signup" - className="text-purple-500 font-B03-M" - onClick={handleSignupClick} - > - 회원가입 - </a> + <Link + to="/signup" + className="text-purple-500 font-B03-M" + onClick={handleSignupClick} + > + 회원가입 + </Link>추가로, 상단 import에 Link 추가 및 useNavigate 제거가 필요합니다:
// import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, Link } from 'react-router-dom';Also applies to: 52-58
src/common/CheckList.tsx (1)
121-123: 업데이트 실패 시에도 편집 모드가 해제되는 버그updateTodo 호출 직후에 편집 상태를 강제로 해제하여(onSuccess, onError와 무관) 실패 시에도 사용자가 편집 모드에서 튕깁니다. 아래처럼 중복/성급한 상태 리셋을 제거해 주세요.
- setEditIndex(null); - setEditText(''); - return; + return;src/pages/jobDetail/JobInfo.tsx (1)
17-23: jobId NaN 처리 및 쿼리 호출 가드 추가
- src/pages/jobDetail/JobInfo.tsx (22행):
Number(jobId)결과가 NaN일 경우useJobDetailQuery호출을 건너뛰거나{ enabled: !Number.isNaN(id) }옵션을 적용해주세요.- 필요 시
useJobDetailQuery훅 시그니처를 확장해 옵션 인자를 받을 수 있도록 수정해야 합니다.src/common/modal/FloatingModal.tsx (1)
23-23: FloatingModal.tsx 23: 로그인 상태 확인 로직 리팩터링
const isLoggedIn = !!localStorage.getItem('accessToken');대신 전역 auth store나 React Context로 토큰/로그인 상태를 관리하고 해당 상태를 구독해 리렌더를 보장하세요.
🧹 Nitpick comments (112)
package.json (1)
16-22: .eslintignore/.prettierignore 파일을 생성하고 src/legacy-components/ 경로를 ignore에 추가
- .eslintignore:
src/legacy-components/- .prettierignore:
src/legacy-components/.husky/pre-commit (1)
6-7: 보안/안정성: npx에 --no-install 플래그, 그리고 set -e 추가 권장
- --no-install: 로컬 devDependency만 실행해 의도치 않은 원격 설치를 막습니다.
- set -e: 중간 실패 시 훅이 즉시 실패하도록 보장합니다.
아래 패치 제안:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" +set -e echo "✅ Lint-staged 시작..." -npx lint-staged +npx --no-install lint-stagedsrc/pages/signup/components/SingupAgree.tsx (5)
53-56: 에러 분기 보완 및 메시지 개선 제안중복 가입 외의 에러에도 동일 알럿이 떠 UX가 떨어집니다. 서버 코드/메시지에 따라 분기하세요. 콘솔 메시지도 일반화하면 재사용성이 좋습니다.
- } catch (error) { - console.error('이미 가입된 이메일 주소입니다.', error); - alert('이미 가입된 이메일 주소입니다.'); - } + } catch (error) { + console.error('이메일 인증 요청 실패', error); + // 서버 규격에 맞춰 코드/메시지 분기 + const code = (error as any)?.response?.data?.code; + const message = + code === 'EMAIL_ALREADY_REGISTERED' + ? '이미 가입된 이메일 주소입니다.' + : '이메일 인증 요청에 실패했습니다. 잠시 후 다시 시도해주세요.'; + alert(message); + }
21-24: 중복 제출 방지: 제출 중 상태 연결react-hook-form의 isSubmitting을 받아 버튼 비활성화로 중복 요청을 막는 것이 안전합니다.
- const { control, handleSubmit } = useForm<EmailOnlyFormValues>({ + const { + control, + handleSubmit, + formState: { isValid, isSubmitting }, + } = useForm<EmailOnlyFormValues>({ resolver: zodResolver(EmailOnlySchema), - mode: 'onChange', + mode: 'onChange', });검증: 아래 버튼 비활성화 변경과 함께 동작 확인 바랍니다.
144-149: 버튼 비활성화 및 접근성 속성 추가필수 동의/유효성/제출 중 상태에서 버튼을 비활성화하면 실수·중복 제출을 줄일 수 있습니다.
- <Button + <Button text="인증번호 전송하기" color="primary" type="submit" - className="mt-[59px] h-[60px] w-full font-T05-SB" + className="mt-[59px] h-[60px] w-full font-T05-SB" + disabled={!allChecked || !isValid || isSubmitting} + aria-disabled={!allChecked || !isValid || isSubmitting} />
87-90: ‘약관보기’ 클릭 요소에 실제 라우팅/링크 연결 필요현재는 단순 텍스트+커서 포인터로, 클릭 시 아무 동작이 없습니다. Link/외부 URL로 연결하거나 onClick 핸들러를 추가하세요.
원하시면 약관/개인정보처리방침 경로 기준으로 라우팅 코드 패치를 제안드리겠습니다.
Also applies to: 101-104, 117-120
19-19: 컴포넌트/파일명 오탈자: Singup → Signup일관성과 검색 용이성을 위해
SingupAgree를SignupAgree로 변경하는 것이 좋습니다. 리네임 시 임포트 경로 전반 영향 검증 필요.원하시면 리네임 영향을 점검하는 스크립트를 제공하겠습니다.
Also applies to: 155-155
src/hook/mypage/useUpdateRegion.ts (1)
11-17: 성공 알림 추가는 좋으나, 캐시 무효화/동기화가 필요합니다성공 후 관련 쿼리를 invalidate하지 않으면 화면 데이터가 갱신되지 않을 수 있습니다. 알럿 대신 토스트 사용도 고려해 주세요.
-import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; ... export const useUpdateRegionMutation = () => { - return useMutation({ + const queryClient = useQueryClient(); + return useMutation({ mutationFn: (payload: UpdateRegionProps) => api.put('/v1/member/region', payload), onSuccess: (response) => { if (response.data.success) { alert('거주지가 성공적으로 변경되었습니다.'); + // 관련 데이터 동기화 + queryClient.invalidateQueries({ queryKey: ['member'] }); + queryClient.invalidateQueries({ queryKey: ['memberRegion'] }); } else { alert('거주지 변경에 실패했습니다.'); } },검증 제안: 실제 회원/지역 정보를 가져오는 queryKey를 확인해 위 키를 맞춰 주세요. 필요 시 onSettled에서 invalidate하면 성공/실패 케이스 모두 동기화됩니다.
src/amplitude.d.ts (1)
1-8: Window.amplitude를 선택적이고 협소한 타입으로 축소any는 타입 검사 이점을 잃습니다. 사용 범위(track)만 노출하는 선택적 속성이 안전합니다.
declare global { interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - amplitude: any; + amplitude?: { + // 사용 중인 메서드만 최소 노출 + track: (eventType: string, eventProperties?: Record<string, unknown>) => void; + }; } } export {};추가 확인: tsconfig.json의 include에
src/**/*.d.ts가 포함되어 있는지 점검해 주세요. 포함되지 않으면 전역 보강이 적용되지 않습니다.src/pages/jobRecommend/JobRecommendPage.tsx (1)
72-80: Amplitude 이벤트: 콘솔 로그 제거 및 불필요한 속성 정리Amplitude가 자체 타임스탬프를 기록하므로 timestamp는 생략 가능하며, console.log는 프로덕션에서 노이즈입니다.
// Amplitude 이벤트 - 온보딩 결과에서 직업 담기 if (window.amplitude) { - window.amplitude.track('job_onboarding_select', { - job_id: jobId, - source_page: location.pathname, - timestamp: new Date().toISOString(), - }); - console.log('Amplitude event sent: job_onboarding_select'); + window.amplitude.track('job_onboarding_select', { + job_id: jobId, + source_page: location.pathname, + }); }참고: PII(개인식별정보) 포함을 지양하고, 이벤트 스키마를 문서화해 일관성을 유지하세요.
src/pages/myTodo/components/scrap/ScrapEmptyState.tsx (1)
10-10: 빈 상태 최소 높이 제거로 인한 레이아웃 점프 가능성
min-h제거 시 뷰포트가 큰 화면에서 세로 중앙정렬이 깨지거나 스크롤 점프가 생길 수 있습니다. 반응형 최소 높이를 유지하는 방향을 권장합니다.다음과 같이 반응형 최소 높이를 부여해 보세요.
- <div className="flex flex-col items-center justify-center space-y-4"> + <div className="flex min-h-[40vh] sm:min-h-[500px] flex-col items-center justify-center space-y-4">src/common/Header.tsx (1)
17-18: 불필요한 조건 제거로 가독성 개선
pathname.startsWith('/jobinfo') && pathname !== '/jobsearch'에서 두 번째 조건은 의미가 없습니다. 간결하게 정리하세요.- pathname.startsWith('/jobfound') || - (pathname.startsWith('/jobinfo') && pathname !== '/jobsearch'), + pathname.startsWith('/jobfound') || pathname.startsWith('/jobinfo'),src/common/Tag.tsx (1)
1-6: 확장성 향상을 위한 className 지원 제안
width/height개별 문자열보다className확장이 유연합니다. 하위 호환 유지하면서className을 병합하도록 제안합니다.interface TagProps { context: string; fontClass?: string; width?: string; height?: string; + className?: string; } const Tag = ({ context, fontClass = 'font-T05-SB', width = 'w-auto', height = 'h-auto', + className = '', }: TagProps) => { return ( <div - className={`flex items-center justify-center rounded-[10px] bg-purple-100 p-2 text-purple-500 ${fontClass} ${width} ${height}`} + className={`flex items-center justify-center rounded-[10px] bg-purple-100 p-2 text-purple-500 ${fontClass} ${width} ${height} ${className}`} > {context} </div> ); }Also applies to: 8-17
src/pages/signup/components/SignupEmailVerify.tsx (2)
65-81: 문자열 비교로 성공 여부 판단은 취약 — 불리언 상태 사용 권장
successMessage === '올바른 인증번호입니다.'와 같은 i18n 의존 문자열 비교는 쉽게 깨집니다. 불리언 상태(isVerified)로 판단하도록 리팩터를 권장합니다.- if (successMessage === '올바른 인증번호입니다.') { + if (isVerified) { // Amplitude 이벤트 전송 if (window.amplitude) { window.amplitude.track('email_verify_submit', { email: email, verification_status: 'success', timestamp: new Date().toISOString(), }); console.log('Amplitude event sent: email_verify_submit'); } onNext(); }추가 변경(선택): 컴포넌트 상단과 검증 useEffect에서 상태를 관리하세요.
// 상단 state const [isVerified, setIsVerified] = useState(false); // 검증 useEffect 내 then/catch .then(() => { setIsVerified(true); setErrorMessage(''); setSuccessMessage('올바른 인증번호입니다.'); }) .catch(() => { setIsVerified(false); setSuccessMessage(''); setErrorMessage('존재하지 않는 인증번호입니다.'); });
100-107: Amplitude로 이메일(PII) 전송 — 정책/동의 확인 및 마스킹 권장이메일은 PII입니다. 이벤트 속성에 원문을 담기 전, 개인정보처리방침/동의 범위 충족 여부를 확인하고 필요 시 해시/마스킹 적용을 권장합니다. 또한 이벤트에 성공/실패 상태와 에러 사유를 함께 남기면 분석 품질이 올라갑니다.
- if (window.amplitude) { - window.amplitude.track('email_verify_resend', { - email: email, - timestamp: new Date().toISOString(), - }); - console.log('Amplitude event sent: email_verify_resend'); - } + if (window.amplitude) { + window.amplitude.track('email_verify_resend', { + email: email, // 정책상 필요 시 해시/마스킹 고려 + status: 'failure', + error_message: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }); + if (process.env.NODE_ENV !== 'production') { + console.log('Amplitude event sent: email_verify_resend'); + } + }src/hook/community/useInfinityScroll.ts (1)
39-54: ref 대상 교체 시 옵저버 재연결 누락 가능성현재
useEffect는ref.current변화를 의존하지 않아, 센티넬 DOM이 재마운트되면 새 노드가 관찰되지 않을 수 있습니다. 콜백 ref 패턴으로 전환을 권장합니다.- const ref = useRef<T | null>(null); - const calledOnceRef = useRef(false); + const calledOnceRef = useRef(false); + const observerRef = useRef<IntersectionObserver | null>(null); - useEffect(() => { - const el = ref.current; - if (!enabled || !el) return; - - if (typeof IntersectionObserver === 'undefined') return; - - const observer = new IntersectionObserver(handleIntersect, { - root, - rootMargin, - threshold, - }); - - observer.observe(el); - return () => observer.disconnect(); - }, [enabled, root, rootMargin, threshold, handleIntersect]); + const setRef = useCallback( + (node: T | null) => { + // 기존 옵저버 정리 + observerRef.current?.disconnect(); + observerRef.current = null; + if (!enabled || !node) return; + if (typeof IntersectionObserver === 'undefined') return; + // 새 노드 관찰 + observerRef.current = new IntersectionObserver(handleIntersect, { + root, + rootMargin, + threshold, + }); + observerRef.current.observe(node); + }, + [enabled, root, rootMargin, threshold, handleIntersect] + ); useEffect(() => { if (!enabled) calledOnceRef.current = false; }, [enabled]); - return ref; + return setRef;src/utils/data/community/jobs.ts (2)
27-34: 이름 완전일치 매칭은 취약 — 트리밍/맵 기반 조회로 안정화 및 역매핑 추가 제안공백 또는 미세한 표기 차이로 매칭 실패할 수 있습니다. O(1) 조회와 안정화를 위해 Map을 도입하고, 역방향 조회도 함께 제공합니다.
API가 반환하는 직업명이 아래 상수와 1:1로 “완전 일치”하는지 확인 부탁드립니다. 불일치 가능성이 있으면 정규화 규칙을 정의해야 합니다.
export const jobs: JobItem[] = [ { id: 1, name: '요양보호사' }, ... { id: 20, name: '산후조리사' }, ]; +// O(1) 조회를 위한 매핑 +export const jobNameToIdMap = new Map(jobs.map((j) => [j.name.trim(), j.id])); +export const jobIdToNameMap = new Map(jobs.map((j) => [j.id, j.name])); + export const jobNames: string[] = jobs.map((j) => j.name); export const findJobIdByName = (name: string): number | null => { - const found = jobs.find((j) => j.name === name); - return found ? found.id : null; + const key = name.trim(); + return jobNameToIdMap.get(key) ?? null; }; +export const findJobNameById = (id: number): string | null => + jobIdToNameMap.get(id) ?? null;
6-27: 정적 데이터 불변화로 런타임 변조 방지(얕은 불변)의도치 않은 수정 방지를 위해 얕은 freeze를 권장합니다.
{ id: 20, name: '산후조리사' }, ]; +// 런타임 변조 방지(얕은 불변) +Object.freeze(jobs);src/validation/home/popularSchema.ts (1)
3-17: 스키마 엄격화: 정수 제약, 타임스탬프 형식, 프로필 이미지 빈 문자열 허용실데이터에서 빈 프로필 이미지("")가 종종 오고, 카운트/ID는 정수여야 합니다. 타임스탬프는 ISO 형식 검증을 권장합니다.
백엔드가 profileImage를 빈 문자열로 보낼 수 있는지 확인 부탁드립니다.
-export const PopularTodoSchema = z.object({ - todoId: z.number(), - title: z.string(), - profileImage: z.string().url(), - memberNickname: z.string(), - memberLevel: z.string().nullable(), - jobName: z.string(), - saveCount: z.number(), -}); +export const PopularTodoSchema = z + .object({ + todoId: z.number().int().positive(), + title: z.string().min(1), + profileImage: z.string().url().or(z.literal('')), + memberNickname: z.string().min(1), + memberLevel: z.string().nullable(), + jobName: z.string().min(1), + saveCount: z.number().int().nonnegative(), + }) + .passthrough(); -export const PopularResponseSchema = z.object({ - success: z.boolean(), - timestamp: z.string(), - data: z.array(PopularTodoSchema), -}); +export const PopularResponseSchema = z.object({ + success: z.boolean(), + timestamp: z.string().datetime(), + data: z.array(PopularTodoSchema), +});src/hook/community/query/useCommunityGetTodo.ts (3)
36-49: 요청 취소 지원(React Query signal 연동)사용자가 빠르게 조건을 바꿀 때 낭비 호출을 줄이기 위해 AbortSignal을 전달하세요.
-const fetchCommunityPage = async ({ - jobName, - level, - sort, - page, - size, -}: BaseParams & { page: number }): Promise<CommunityGetTodoResponse> => { +const fetchCommunityPage = async ( + { jobName, level, sort, page, size }: BaseParams & { page: number }, + signal?: AbortSignal, +): Promise<CommunityGetTodoResponse> => { @@ - const res = await api.get('/v1/community/todos', { + const res = await api.get('/v1/community/todos', { params: { jobName, level, sort, page, size }, - ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), + ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), + signal, });
65-78: queryKey 안정화 및 불필요한 호출 방지(enabled)객체 전체를 키로 쓰면 참조 변화에 민감합니다. 원시 필드로 분해하고, 필수 파라미터가 유효할 때만 요청하세요.
return useInfiniteQuery({ - queryKey: ['CommunityGetTodo', params], + queryKey: [ + 'CommunityGetTodo', + params.jobName, + params.level, + params.sort, + params.size, + ], initialPageParam: 0, - queryFn: ({ pageParam }) => - fetchCommunityPage({ ...params, page: pageParam as number }), + queryFn: ({ pageParam, signal }) => + fetchCommunityPage({ ...params, page: pageParam as number }, signal), @@ - retry: 0, + retry: 0, + enabled: Boolean(params.jobName && params.level && params.sort && params.size), });
29-34: level/sort 값 도메인 제한 권장백엔드 허용값(예: level: 'BEGINNER'|'INTERMEDIATE'|'ADVANCED', sort: 'LATEST'|'SAVED' 등)이 명확하다면 union 타입으로 제한하세요.
허용 가능한 level/sort 목록을 알려주시면 타입으로 반영하는 PR 패치를 제안드릴 수 있습니다.
src/pages/jobDetail/components/WorkStrong.tsx (2)
31-33: 아이콘 버튼에 접근성 속성 및 type 지정폼 내부 포함 가능성을 대비해 type="button" 지정과 스크린리더용 라벨이 필요합니다.
- <button onClick={() => toggleTooltip('physical')}> + <button + type="button" + aria-label="직무 활동량 설명 토글" + onClick={() => toggleTooltip('physical')} + > <Info className="text-[#A1A6B5]" /> </button> - <button onClick={() => toggleTooltip('stress')}> + <button + type="button" + aria-label="정신적 스트레스 설명 토글" + onClick={() => toggleTooltip('stress')} + > <Info className="text-[#A1A6B5]" /> </button> - <button onClick={() => toggleTooltip('relationship')}> + <button + type="button" + aria-label="대인관계 빈도 설명 토글" + onClick={() => toggleTooltip('relationship')} + > <Info className="text-[#A1A6B5]" /> </button>Also applies to: 49-51, 67-69
22-22: 제목에 시맨틱 태그 사용 권장h2/h3 등 헤딩을 사용하면 접근성/내비게이션이 개선됩니다.
- <div className="text-gray-900 font-T05-SB"> 업무 강도 </div> + <h2 className="text-gray-900 font-T05-SB">업무 강도</h2>src/pages/login/LoginPage.tsx (1)
26-26: 콘솔 로그 제거 또는 환경 분기프로덕션 콘솔 노이즈를 줄이기 위해 로그는 제거하거나 NODE_ENV === 'development' 조건으로 감싸주세요.
- console.log('Amplitude event sent: signup_start'); // 콘솔에서 전송 확인용 + // dev 전용 로그가 필요하면 환경 분기 후 출력하세요.src/pages/login/components/LoginForm.tsx (2)
19-27: 이벤트 페이로드 유효성 검토: has_id/has_password는 항상 true 가능성zodResolver 통과 후에만 onSubmit이 호출되어 두 필드는 대부분 true가 됩니다. 미입력 시도까지 관측하려면 버튼 onClick(검증 전)에서 이벤트를 쏘거나 handleSubmit의 onInvalid 훅을 사용해 실패 케이스를 별도 이벤트로 기록하세요.
예시(실패 케이스 트래킹 추가):
const onInvalid = (errors: FieldErrors<LoginFormValues>) => { if (window?.amplitude?.track) { window.amplitude.track('login_validate_fail', { has_id: !errors.id, has_password: !errors.password, timestamp: new Date().toISOString(), }); } }; // ... <form onSubmit={handleSubmit(onSubmit, onInvalid)} ...>
26-26: 콘솔 로그 제거 또는 환경 분기프로덕션 빌드에서 콘솔 출력은 지양합니다.
- console.log('Amplitude event sent: click_login'); // 콘솔에서 전송 확인용 + // 필요 시 개발 환경에서만 출력src/hook/mypage/useMypageQuery.ts (2)
20-38: 쿼리 의존성/테스트 용이성 개선: 토큰 주입 및 queryKey 반영
- localStorage 접근을 한 곳으로 모으고, API 함수에 토큰을 주입하면 테스트/SSR 안전성이 높아집니다.
- 토큰(또는 사용자 식별자)을 queryKey에 포함하지 않으면 로그인 전환 시 캐시 재사용으로 오표시가 생길 수 있습니다(전역 캐시 리셋으로 관리하지 않는 경우).
-const Mypage = async () => { +const Mypage = async (token: string) => { try { - const token = localStorage.getItem('accessToken'); - if (!token) { throw new Error('인증 토큰이 없습니다'); } const response = await api.get('/v1/member/info', { headers: { Authorization: `Bearer ${token}`, }, }); return response.data.data; } catch (error) { console.error('내 계정 정보를 불러오는 것에 실패했습니다.', error); throw error; } }; export const useGetInfo = () => { - const token = - typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null; + const token = + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null; return useQuery<GetInfoProps>({ - queryKey: ['Mypage'], - queryFn: () => Mypage(), + queryKey: ['Mypage', token], + queryFn: () => Mypage(token as string), enabled: !!token, }); };로그인/로그아웃 시 QueryClient 캐시를 확실히 리셋하고 있다면 queryKey 변경은 선택 사항입니다. 현재 전략을 확인 부탁드립니다.
Also applies to: 41-47
20-38: 토큰 저장소 보안 고려(장기 과제)localStorage 보관은 XSS에 취약합니다. 가능하다면 httpOnly/secure 쿠키 기반 세션으로 전환하고, 401 시 자동 로그아웃/캐시 비우기 흐름을 정립하는 것을 권장합니다.
src/hook/home/usePopularQuery.ts (2)
12-12: 엔드포인트 경로 표기 일관성 확인 필요동일 코드베이스 내 다른 훅들이
'/v1/...'형태를 사용합니다. 현재만'v1/...'로 시작해 베이스 URL 결합 방식에 따라 미묘한 차이가 날 수 있습니다. 팀 컨벤션에 맞춰 통일하는 것이 안전합니다.- const response = await api.get('v1/todo/popular'); + const response = await api.get('/v1/todo/popular');
9-16: 캐싱 옵션으로 불필요한 재요청 줄이기홈 슬라이더 데이터는 초단위 신선도가 크게 중요하지 않다면
staleTime/gcTime을 지정하면 깜빡임과 네트워크 부하가 줄어듭니다.return useQuery<PopularTodo[]>({ queryKey: ['popular'], queryFn: async () => { const response = await api.get('/v1/todo/popular'); const validatedData = PopularResponseSchema.parse(response.data); return validatedData.data; }, + staleTime: 60 * 1000, // 1분 + gcTime: 5 * 60 * 1000, // 5분 });src/pages/jobDetail/components/RecommendTodo.tsx (2)
9-12: ‘더 많은 할 일 보기’ 클릭 액션 미연결UI가 클릭 가능한 affordance를 주지만 실제 네비게이션/핸들러가 없습니다. 라우팅 또는 콜백을 연결해 주세요. 필요시
onMoreClick(optional) prop 추가로 해결 가능합니다.
6-6: 고정 폭 대신 반응형 최대폭으로 유연성 확보데스크톱 이외 해상도에서 레이아웃 깨짐을 방지하려면
w-full max-w-[712px]로 전환을 권장합니다.- <div className="mt-6 flex w-[712px] flex-col items-start rounded-[30px] border border-gray-300 bg-white px-[30px] py-10"> + <div className="mt-6 flex w-full max-w-[712px] flex-col items-start rounded-[30px] border border-gray-300 bg-white px-[30px] py-10">src/common/FloatingButton.tsx (1)
29-29: prod 콘솔 출력 제거 권장콘솔 로그는 개발 단계 외에는 노이즈가 됩니다. 필요시 env 기반으로 가드하거나 제거하세요.
- console.log('Amplitude event sent: todo_create (floating)'); + // console.debug('Amplitude event sent: todo_create (floating)');src/hook/community/query/useGetCommunityQuery.ts (2)
11-19: 인증 헤더 주입은 인터셉터로 공통화하는 것이 낫습니다매 훅에서 토큰을 읽고 헤더를 꾸리는 중복을 줄이기 위해 Axios 인터셉터/미들웨어로 이동하면 유지보수가 쉬워집니다.
예시(별도 파일):
api.interceptors.request.use((config) => { if (typeof window !== 'undefined') { const token = localStorage.getItem('accessToken'); if (token) { config.headers = { ...(config.headers ?? {}), Authorization: `Bearer ${token}` }; } } return config; });
21-21: 응답 스키마 검증/타이핑 추가 권장
res.data를 그대로 반환하면 런타임 스키마 드리프트에 취약합니다. zod(or io-ts)로 파싱 후 타입 안정성을 확보해 주세요.src/hook/jobinfo/useAddMyTodoMutation.ts (1)
5-5: 네이밍 컨벤션 정리함수는 카멜케이스(
addMyTodo) 권장. 위 diff에 포함했습니다.src/common/CheckList.tsx (5)
76-87: Amplitude 이벤트: 메시지/속성 불일치 및 location 참조 일관화
- 콘솔 메시지의 이벤트명이 track 호출과 다릅니다.
- pathname은 window.location 대신 react-router의 location을 일관 사용 권장.
- source_page: window.location.pathname, + source_page: location.pathname, @@ - console.log('Amplitude event sent: todo_create_attempt (inpage)'); + console.log('Amplitude event sent: todo_create (inpage)');
91-99: Amplitude에 성공/실패 이벤트도 함께 적재하여 퍼널 가시성 확보생성 시도만 트래킹 중입니다. onSuccess/onError에서 성공/실패 이벤트를 추가해 주면 퍼널 분석이 명확해집니다.
mutate( { todoTitle: trimmedText }, { - onSuccess: () => { + onSuccess: () => { + if (window.amplitude) { + window.amplitude.track('todo_create_succeeded', { + source_method: 'inpage', + source_page: location.pathname, + todo_length: trimmedText.length, + timestamp: new Date().toISOString(), + }); + } setIsAddingNew(false); setEditIndex(null); setEditText(''); }, - onError: () => { + onError: () => { + if (window.amplitude) { + window.amplitude.track('todo_create_failed', { + source_method: 'inpage', + source_page: location.pathname, + todo_length: trimmedText.length, + timestamp: new Date().toISOString(), + }); + } alert('할 일 추가에 실패했습니다.'); }, } );
266-273: UI에 하드코딩된 수치(3, 999)는 사용자 혼란 유발의도적 placeholder가 아니라면 제거하거나 실제 데이터와 연결해 주세요. 최소한 AB 테스트/디자인 검증 전까지는 노출을 피하는 편이 안전합니다.
- <div className="mr-3 flex items-center gap-1 text-gray-500"> - <AddIcon className="h-[18px] w-[18px]" /> - <span className="text-sm font-B03-SB">3</span> - </div> - <div className="mr-3 flex items-center gap-1 text-gray-500"> - <BookMarkIcon className="h-[18px] w-[18px]" /> - <span className="text-sm font-B03-SB">999</span> - </div> + {/* TODO: 실제 카운트 연동 전까지 숨김 또는 툴팁 등으로 대체 */}
155-159: setTimeout 클린업 없음(언마운트 시 메모리 누수/상태 업데이트 경고 가능성)토스트 타이머를 ref로 보관하고 언마운트 시 clearTimeout 해 주세요.
아래는 파일 상단부에 추가할 예시(선택 적용):
// 추가 import import { useEffect, useRef } from 'react'; // 컴포넌트 내부 const toastTimerRef = useRef<number | null>(null); // setTimeout 설정부 toastTimerRef.current = window.setTimeout(() => { /* ... */ }, 2500); // 언마운트 클린업 useEffect(() => { return () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); }; }, []);
11-11: .ts 확장자 명시 불필요(번들러 호환성 및 일관성)동일 레포 내 다수 모듈이 확장자를 생략합니다. 일관성 및 일부 툴링 호환성 위해 확장자 제거를 권장합니다.
-import { useAddTodoMutation } from '@hook/todo/useAddTodoMutation.ts'; +import { useAddTodoMutation } from '@hook/todo/useAddTodoMutation'; @@ -import { useUpdateTodoMutation } from '@hook/todo/useUpdateTodoMutation.ts'; +import { useUpdateTodoMutation } from '@hook/todo/useUpdateTodoMutation';Also applies to: 15-15
src/pages/community/Community.tsx (1)
6-9: sticky 동작 범위와 컨테이너 overflow 확인부모에 overflow가 걸려 있거나 높이 제약이 있으면 sticky가 기대대로 동작하지 않을 수 있습니다. Safari에서도 확인 부탁드립니다. 또한 px-[120px]은 데스크톱 전용처럼 보이므로 sm/md 브레이크포인트에서 패딩 축소를 권장합니다.
src/pages/home/components/Slider.tsx (3)
26-28: map key로 index 사용 지양태그가 변경/재정렬 시 불필요한 리렌더를 유발할 수 있습니다. 값이 유니크하다면 값 기반 key로 교체하세요.
- {tags.map((tag, index) => ( - <Tag key={index} context={tag} fontClass={'font-B03-M'} /> + {tags.map((tag) => ( + <Tag key={tag} context={tag} fontClass={'font-B03-M'} /> ))}
1-2: 경량 최적화: memo 적용 및 .tsx 확장자 제거슬라이드가 많은 경우 렌더 비용을 줄이기 위해 memo를 적용하고, import 확장자 일관성을 맞추면 좋습니다.
+import { memo } from 'react'; -import Tag from '@common/Tag.tsx'; +import Tag from '@common/Tag'; @@ -export default Slider; +export default memo(Slider);Also applies to: 36-36
19-21: 대체 텍스트 개선alt를 더 구체적으로 또는 장식용이면 빈 문자열로 처리해 스크린리더 소음을 줄여 주세요.
- alt="프로필이미지" + alt="사용자 프로필 이미지"또는 장식용일 경우:
- alt="프로필이미지" + alt="" + aria-hidden="true"src/pages/home/components/LoginBanner.tsx (4)
7-7: import 경로의 .tsx 확장자 제거 권장Vite/TS 경로 표기 일관성을 위해 확장자 생략을 추천합니다.
-import SliderContainer from '@pages/home/components/SliderContainer.tsx'; +import SliderContainer from '@pages/home/components/SliderContainer';
15-15: localStorage 직접 참조로 인한 비반응성/SSR 위험스토어를 이미 사용 중이므로 닉네임도 zustand에서 읽고, 필요 시에만 localStorage로 폴백하세요. SSR 환경을 고려해 window 가드도 추가합니다.
- const nickname = localStorage.getItem('nickname'); + const nickname = + useUserStore((s) => s.nickname) || + (typeof window !== 'undefined' + ? localStorage.getItem('nickname') || '' + : '');
18-23: levelLabel 계산은 useMemo로 캐시하세요렌더마다 불변 로직을 실행할 필요가 없습니다. 사소하지만 재렌더 절약에 도움 됩니다.
- const levelLabel = (() => { + const levelLabel = useMemo(() => { const level = myInfo?.level?.trim(); if (!level) return null; const normalized = level.replace(/\s*단계\s*$/u, ''); return normalized.length > 0 ? normalized : null; - })(); + }, [myInfo?.level]);추가:
import { useMemo } from 'react';
26-34: 닉네임 미존재 시 안전한 대체 텍스트 적용null/빈 문자열일 때 “회원” 등 기본값을 표시하면 UX가 안정적입니다.
- {nickname}님 + {(nickname || '회원')}님src/pages/jobSelect/JobSelect.tsx (4)
68-77: window 직접 참조를 SSR 안전하게 가드CSR만 가정하더라도 예방 차원에서
typeof window !== 'undefined'가드를 추천합니다. 동일 패턴을 전역 적용하면 안전합니다.- if (window.amplitude) { + if (typeof window !== 'undefined' && window.amplitude) { window.amplitude.track('job_select', { method: 'job_add', job_id: pendingJob.id, job_name: pendingJob.name, source_page: location.pathname, timestamp: new Date().toISOString(), }); }- if (window.amplitude) { + if (typeof window !== 'undefined' && window.amplitude) { window.amplitude.track('job_select', { method: 'job_recommend', source_page: location.pathname, timestamp: new Date().toISOString(), }); } @@ - if (window.amplitude) { + if (typeof window !== 'undefined' && window.amplitude) { window.amplitude.track('job_onboarding_start', { source_page: location.pathname, referrer: document.referrer, timestamp: new Date().toISOString(), }); }Also applies to: 98-120
58-66: GTM 이벤트 페이로드에 타임스탬프 추가 제안분석 정합성 향상을 위해 GTM에도 동일 기준의 ISO 타임스탬프를 포함하면 좋습니다.
ReactTagManager.action({ event: 'job_select', job_id: pendingJob.id, category: 'JobSelect', clickText: '직업 담기', source_page: location.pathname, + timestamp: new Date().toISOString(), });
86-87: 언마운트 후 setState 호출 가능성navigate 직후 컴포넌트가 언마운트되면
setShowToast(false)가 경고를 유발할 수 있습니다. 언마운트 가드로 방지하세요.// 컴포넌트 상단 const unmountedRef = useRef(false); useEffect(() => () => { unmountedRef.current = true; }, []); // onSuccess 내부 const t = setTimeout(() => { if (!unmountedRef.current) setShowToast(false); }, 2000);
98-120: 분석 이벤트 유틸로 공통화 고려이 파일을 포함해 다수 위치에서 동일 패턴의 Amplitude/GTM 호출이 반복됩니다.
useAnalytics().track(event, payload)형태로 래핑해 로깅/가드/표준 필드(tenant, source_page, ts) 일관화하세요.src/store/useCommunityStore.ts (2)
3-7: 상태 묶음(객체)으로 관리 + 타입 세분화 제안
selectedJobName/Id대신{ id, name }객체 하나로 다루면 전달/초기화가 단순해집니다.-interface CommunityStore { - selectedJobName: string; - selectedJobId: number | null; - setSelectedJob: (job: { name: string; id: number | null }) => void; -} +interface SelectedJob { + id: number | null; + name: string; +} +interface CommunityStore { + selectedJob: SelectedJob | null; + setSelectedJob: (job: SelectedJob | null) => void; +}-export const useCommunityStore = create<CommunityStore>((set) => ({ - selectedJobName: '', - selectedJobId: null, - setSelectedJob: ({ name, id }) => - set({ selectedJobName: name, selectedJobId: id }), -})); +export const useCommunityStore = create<CommunityStore>((set) => ({ + selectedJob: null, + setSelectedJob: (job) => set({ selectedJob: job }), +}));
1-1: 선택값 유지가 필요하다면 persist 적용 고려커뮤니티 탭 간 이동 시 선택값 유지가 필요하면
zustand/middleware의persist로 localStorage에 저장하세요.src/pages/onboard/OnBoardingPage.tsx (2)
49-58: GTM 완료 이벤트에 source_page 포함 권장완료 이벤트에도 페이지 컨텍스트를 함께 보내면 분석 연결이 쉬워집니다.
ReactTagManager.action({ event: 'onboarding_complete', category: '온보딩', clickText: '온보딩 질문 완료', step: curStep + 1, question: curQuestionIndex + 1, + source_page: window.location.pathname, + timestamp: new Date().toISOString(), });SSR이 우려되면
typeof window !== 'undefined'가드를 함께 추가하세요.
59-69: Amplitude 호출 SSR 안전 가드 추가
window가 정의되지 않은 환경 대비를 위해 가드를 추가하세요.- if (window.amplitude) { + if (typeof window !== 'undefined' && window.amplitude) { window.amplitude.track('job_onboarding_complete', { source_page: window.location.pathname, step: curStep + 1, question: curQuestionIndex + 1, total_steps: stepQuestions.length, timestamp: new Date().toISOString(), }); }src/pages/signup/components/Signup2.tsx (2)
245-252: setIsModal(false) 중복 호출onClose 내부에서 두 번 호출되고 있습니다. 한 번만 호출하세요.
- onClose={(selectedAddress?: string, selectedRegionCode?: string) => { - if (selectedAddress && selectedRegionCode) { - setAddress(selectedAddress); - setRegionCode(selectedRegionCode); - setIsModal(false); - } - setIsModal(false); - }} + onClose={(selectedAddress?: string, selectedRegionCode?: string) => { + if (selectedAddress && selectedRegionCode) { + setAddress(selectedAddress); + setRegionCode(selectedRegionCode); + } + setIsModal(false); + }}
177-205: 생년월일(displayBirthDate) 초기값 폼 값과 동기화
Signup2Schema의date필드는YYYY/MM/DD형식을 기대하므로, 초기 렌더링 시displayBirthDate를 폼 값과 동기화하는 useEffect를 추가하세요.useEffect(() => { const v = getValues('date'); // 'YYYY/MM/DD' if (v) { setDisplayBirthDate(formatBirthDate(v.replace(/\D/g, ''))); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []);src/hook/community/useDeleteCommunityTodos.ts (3)
11-23: Authorization 헤더/토큰 체크는 공용 인터셉터로 이관 권장여기서 직접 localStorage를 읽고 헤더를 설정하면 중복 로직이 생기고 토큰 갱신(리프레시) 흐름과 쉽게 어긋납니다. api 인스턴스에 요청 인터셉터가 이미 있다면 이 훅에서는 URL/파라미터만 다루도록 단순화하세요. 없다면 인터셉터 도입을 권장합니다.
원하시면 axios 인터셉터 스켈레톤을 제안드릴게요.
18-21: DELETE 메서드에 body(data) 전달 불필요 가능성id가 URL 경로에 포함되어 있어 data 페이로드가 필요 없을 수 있습니다. 서버가 요구하지 않는다면 제거해 preflight 가능성을 줄이고 단순화하세요.
서버가 body를 요구하는지 확인해 주세요. 불필요하다면 아래처럼 간소화 가능합니다.
- const response = await api.delete(`/v1/community/todos/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - data: { id }, - }); + const response = await api.delete(`/v1/community/todos/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + });
11-11: mutationKey 추가로 디버깅/Devtools 가독성 향상mutationKey를 지정하면 디버깅과 캐시 추적이 쉬워집니다.
- return useMutation({ + return useMutation({ + mutationKey: ['CommunityDeleteTodo'],src/pages/home/components/Banner.tsx (3)
5-6: 모듈 경로에서 확장자 제거로 일관성 유지별칭 경로에는 확장자를 생략하는 컨벤션을 따르는 것이 일반적입니다. 번들러 설정에 따라 불필요한 경로 해석 문제가 줄어듭니다.
-import Tag from '@common/Tag.tsx'; +import Tag from '@common/Tag';
43-51: 빈 데이터/로딩 상태 UI 보강jobList가 없을 때의 플레이스홀더(스켈레톤/메시지)를 노출하면 UX가 좋아집니다. 또한 key로 job['job-name'] 사용 시 중복 가능성에 대비해 count나 인덱스 조합을 고려하세요.
API 응답 스키마에서 필드명이 'job-name'이 맞는지 확인해 주세요(타이핑이 있다면 인덱스 접근 대신 안전한 타입 속성 사용 권장).
27-33: 네비게이션 요소에 Link 사용 고려div+onClick 대신 react-router의 Link 컴포넌트를 쓰면 접근성/포커스 처리 및 SEO(SSR 시)에 유리합니다.
- <div - className="flex cursor-pointer flex-row items-center" - onClick={() => navigate('/jobsearch')} - > + <Link to="/jobsearch" className="flex cursor-pointer flex-row items-center"> <div className="text-gray-500 font-B02-SB"> 채용 정보 보기 </div> <Arrow /> - </div> + </Link>src/pages/jobDetail/components/JobView.tsx (4)
20-21: 커스텀 훅의 동적 선택 패턴 정리 권장isLoggedIn에 따라 훅 함수를 변수에 담아 호출하는 패턴은 추후 내부 훅 구성 차이가 생기면 규칙 위반 리스크가 있습니다. 하나의 훅으로 통합하거나 enabled 옵션으로 분기하세요.
- const query = isLoggedIn ? useJobViewQuery : useNoJobViewQuery; - const { data: jobView, isLoading, error } = query(jobName); + const { data: jobView, isLoading, error } = useJobViewQuery(jobName, { enabled: isLoggedIn }); + const { data: jobViewAnon } = useNoJobViewQuery(jobName, { enabled: !isLoggedIn }); + const data = isLoggedIn ? jobView : jobViewAnon;
40-47: 아이템 수 제한 매직 넘버 상수화slice(0, 2)는 의도를 드러내는 상수로 추출하면 유지보수성이 좋아집니다(디자인 변경 시 한 곳에서 조정).
+ const VISIBLE_COUNT = 2; ... - jobView.slice(0, 2).map((view) => ( + jobView.slice(0, VISIBLE_COUNT).map((view) => (
71-80: 검색 페이지 프리페치/선택값 동기화버튼 클릭 시 setSelection만 설정하고 이동하는데, 검색 페이지가 해당 상태를 즉시 소비한다면 prefetch로 초기 렌더를 가속할 수 있습니다. 또한 jobId 매핑 유틸이 있다면 id까지 함께 저장하면 필터 일관성이 올라갑니다.
원하시면 prefetch/스토어 동기화 코드 스니펫을 드릴게요.
31-32: 에러 상태에 재시도 액션 제공단순 문구 대신 “다시 시도” 버튼(onClick에서 query 재호출)을 제공하면 복구 가능성이 높아집니다.
src/hook/community/useCommunityAddTodoMutation.ts (2)
24-38: Authorization/토큰 처리의 중복 로직 정리Delete 훅과 마찬가지로 인터셉터로 위임하면 재사용성과 보안 흐름(401 처리, 토큰 갱신)이 좋아집니다.
원하시면 인터셉터 템플릿과 에러 공통 처리(토스트/로그) 유틸을 제안드릴게요.
19-23: mutationKey 지정으로 디버깅 용이성 향상Devtools에서 구분이 쉬워집니다.
return useMutation< CommunityAddTodoResponse, unknown, CommunityAddTodoRequest >({ + mutationKey: ['CommunityAddTodo'],src/pages/home/components/SliderContainer.tsx (1)
52-66: prefers-reduced-motion 고려(접근성)자동 슬라이딩은 모션 민감 사용자에게 부담이 됩니다. 사용자의 모션 감소 설정을 감지해 자동 롤링을 끄는 옵션을 권장합니다.
원하시면 간단한 useReducedMotion 훅을 포함한 패치 제안 가능합니다.
src/pages/jobDetail/components/TabContent/ReadyContent.tsx (5)
4-4: 타이머 정리 누락으로 언마운트 후 setState 경고 가능setTimeout 정리 없이 언마운트되면 경고가 날 수 있습니다. useRef로 타이머 id를 보관하고 cleanup 하세요.
적용 예시:
-import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react';- setTimeout(() => setShowToast(false), 2000); + timeoutRef.current = window.setTimeout(() => setShowToast(false), 2000);컴포넌트 내부(상태 선언 인근)에 추가:
const timeoutRef = useRef<number | null>(null); useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []);
5-5: import 경로에서 확장자(.ts) 제거 권장Path alias 사용 시 확장자 표기는 도구별 호환성 이슈를 유발할 수 있습니다. 코드베이스와 일관되게 확장자를 제거하세요.
-import { useAddMyTodoMutation } from '@hook/jobinfo/useAddMyTodoMutation.ts'; +import { useAddMyTodoMutation } from '@hook/jobinfo/useAddMyTodoMutation';
82-82: 문구 띄어쓰기 수정한국어 맞춤법: “추가되었습니다”로 붙여 쓰는 것이 맞습니다.
- text="내 할일 목록에 추가 되었습니다" + text="내 할일 목록에 추가되었습니다"
53-55: truncate 텍스트 접근성 보완잘림 시 전체 내용을 볼 방법이 없습니다. title 속성 추가를 권장합니다.
- <div className="max-w-[516px] truncate text-gray-500 font-B01-M"> - {seed.title} - </div> + <div + className="max-w-[516px] truncate text-gray-500 font-B01-M" + title={seed.title} + > + {seed.title} + </div>
7-10: 미사용 prop 정리 검토: jobIdReadyContent 내부에서 jobId를 사용하지 않습니다. 불필요하면 props와 상위 호출부에서 제거하여 인터페이스를 간결하게 하세요. 혹 필요 예정이라면 주석으로 의도를 남겨 주세요.
Also applies to: 12-13
src/pages/jobDetail/components/DetailTab.tsx (2)
70-104: ARIA 탭 접근성 보완(aria-selected, aria-controls, tabpanel 추가)역할만 있고 ARIA 상태/연결이 없습니다. 스크린리더 접근성을 위해 최소 속성을 추가하세요.
- <button + <button key={t.key} role="tab" onClick={() => setActive(t.key)} className={[ 'relative px-4 py-[10px]', isActive ? 'text-purple-500 font-B02-SB' : 'text-gray-400 font-B02-M', ].join(' ')} + id={`tab-${t.key}`} + aria-selected={isActive} + aria-controls={`panel-${t.key}`} + tabIndex={isActive ? 0 : -1} >- <div className="pt-6">{renderContent()}</div> + <div + className="pt-6" + role="tabpanel" + id={`panel-${active}`} + aria-labelledby={`tab-${active}`} + > + {renderContent()} + </div>
1-27: 사전 패치(prefetch)로 탭 전환 지연 줄이기(선택)활성 탭 변경 시 인접 탭의 데이터를 prefetch하면 전환 속도가 개선됩니다. queryClient.prefetchQuery 사용을 고려하세요.
src/pages/home/Home.tsx (3)
43-52: 로그아웃 시 모달 상태 초기화로그아웃하면 기존 true 상태가 남을 수 있습니다. isLoggedIn이 false일 때 닫아 주세요.
- useEffect(() => { - if (!isLoggedIn) return; + useEffect(() => { + if (!isLoggedIn) { + setPrepareOpen(false); + return; + } if (!myInfo) return; - const hasLevel = Boolean( - myInfo.level && String(myInfo.level).trim().length > 0 - ); + const hasLevel = + myInfo.level !== null && + myInfo.level !== undefined && + String(myInfo.level).trim().length > 0; if (!hasLevel) { setPrepareOpen(true); } }, [isLoggedIn, myInfo]);
32-41: 토스트 setTimeout 정리 추가언마운트 타이밍에 정리되지 않습니다. 클린업을 추가하세요.
- useEffect(() => { - if (location.state?.toast) { + useEffect(() => { + let t: number | undefined; + if (location.state?.toast) { setToast(location.state.toast); navigate(location.pathname, { replace: true }); - setTimeout(() => { + t = window.setTimeout(() => { setToast(null); }, 1500); } - }, [location.state, location.pathname, navigate]); + return () => { + if (t) clearTimeout(t); + }; + }, [location.state, location.pathname, navigate]);
69-70: 배너 조건 가독성 개선(선택)동일 컴포넌트 반복을 줄여 가독성을 높일 수 있습니다.
- {!isLoggedIn ? <Banner /> : hasNoJob ? <Banner /> : <LoginBanner />} + {!isLoggedIn || hasNoJob ? <Banner /> : <LoginBanner />}src/pages/jobDetail/JobInfo.tsx (1)
108-110: 죽은 코드 여부 확인: AddJobModal 트리거 부재
isModalOpen을 true로 바꿔줄 UI/로직이 없습니다. 유지 의도면 트리거를 추가하고, 아니면 모달/상태를 제거하세요.src/hook/community/query/useGetHotPopularQuery.ts (3)
32-32: 프로덕션 콘솔 로그 제거 권장민감 데이터/노이즈 유출 방지 및 번들 크기 최적화를 위해 콘솔 로그를 제거해 주세요.
- console.log(res.data);
25-38: 쿼리 UX 보강: 데이터 깜빡임 방지 및 과도 재시도 억제job 변경 시 이전 데이터 유지와 실패 재시도 제한을 권장합니다.
return useQuery<HotPopularItem[]>({ queryKey: ['hotPopular', selectedJobName], enabled: !!selectedJobName, queryFn: async () => { const res = await api.get('/v1/community/todos/popular', { params: { jobName: selectedJobName }, }); - const body = res.data as HotPopularApiResponse; - return Array.isArray(body?.data) ? body.data : []; + const body = res.data as HotPopularApiResponse; + return Array.isArray(body?.data) ? body.data : []; }, staleTime: 1000 * 60 * 5, refetchOnWindowFocus: false, + keepPreviousData: true, + placeholderData: [], + retry: 1, });
5-14: 게스트 응답 호환성: isSaved를 optional로 지정게스트/비로그인 인기 목록에서 사용자 종속 필드(isSaved)가 누락될 수 있습니다. 런타임 안전성을 위해 optional을 권장합니다.
API 스키마가 항상 isSaved를 반환하는지 확인해 주세요.
export interface HotPopularItem { id: number; name: string; level: string; imageUrl: string; dDay: string; description: string; saveCount: number; - isSaved: boolean; + isSaved?: boolean; }src/common/modal/FloatingModal.tsx (3)
111-114: 문구 다듬기: 띄어쓰기 수정사용자 노출 문구의 가독성을 위해 띄어쓰기를 수정하세요.
- : (hotList?.title ?? '인기데이터가 없습니다')} + : (hotList?.title ?? '인기 데이터가 없습니다')}
117-122: 접근성: 클릭 가능한 div → button 전환 및 aria-label 추가키보드 접근성과 스크린리더 호환을 위해 button 요소 사용과 라벨을 권장합니다.
- <div - className="shrink-0 cursor-pointer" - onClick={() => refreshHot()} - > - <Reset /> - </div> + <button + type="button" + aria-label="인기 새로고침" + className="shrink-0 cursor-pointer" + onClick={() => refreshHot()} + > + <Reset /> + </button>
70-71: 디버그 로그 제거Amplitude 이벤트는 정상 추적 중이므로 개발용 콘솔 로그는 제거해 주세요. 필요 시 디버그 플래그로 가드하세요.
- console.log('Amplitude event sent: todo_create_attempt');src/pages/community/components/CommunityLeftSide.tsx (4)
19-23: 불필요 콘솔 제거 또는 no-op 처리드롭다운 내부에서 이미 전역 상태를 갱신합니다. 상위 onSelect에서의 콘솔 출력은 불필요합니다.
- onSelect={(value) => { - console.log(value); - }} + onSelect={() => {}}
41-44: 빈 선택 대비 타이틀 폴백 제공선택되지 않은 초기 상태에서 공백 타이틀을 방지하세요.
- {selectedJobName} HOT 할 일 + {(selectedJobName || '전체')} HOT 할 일
54-58: 이미지 로딩 최적화아바타 이미지에 지연 로딩 및 비동기 디코딩을 추가하세요.
<img src={item.imageUrl} alt="프로필이미지" + loading="lazy" + decoding="async" className="h-[30px] w-[30px] rounded-full bg-gray-50" />
45-82: 빈 목록 처리: 사용자 피드백 제공
popularTodos가 비어 있을 때 안내 문구를 표시해 UX를 개선하세요.- <div className="mt-[37px] flex w-full flex-col items-start"> - {popularTodos.map((item, idx) => ( + <div className="mt-[37px] flex w-full flex-col items-start"> + {popularTodos.length === 0 ? ( + <div className="text-gray-500 font-C01-R">인기 할 일이 없습니다.</div> + ) : popularTodos.map((item, idx) => ( <div key={item.id} className="flex w-full flex-row items-start justify-between py-4" > ... - ))} + ))} </div>src/pages/jobDetail/components/TabContent/TreeContents.tsx (2)
2-2: setTimeout 클린업 누락 — 언마운트 시 상태 업데이트 경고 가능언마운트 직후 타이머가 setState를 호출할 수 있습니다. ref로 타이머를 보관하고 useEffect로 정리하세요.
-import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; @@ - const [showToast, setShowToast] = useState(false); + const [showToast, setShowToast] = useState(false); + const timerRef = useRef<number | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); @@ - setShowToast(true); - setTimeout(() => setShowToast(false), 2000); + setShowToast(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setShowToast(false), 2000);Also applies to: 18-31
24-32: 오류 피드백 부재mutate 실패 시 사용자 피드백이 없습니다. 최소한 실패 토스트나 콘솔 메시지를 추가해 주세요.
mutate(jobTodoId, { onSuccess: () => { @@ }, - onSettled: () => setClickedId(null), + onError: () => { + // TODO: 에러 토스트/알림 처리 + console.error('내 할일 추가에 실패했습니다.'); + }, + onSettled: () => setClickedId(null), });src/pages/jobDetail/components/TabContent/SproutContent.tsx (4)
2-2: setTimeout 클린업 누락 — 언마운트 시 상태 업데이트 경고 가능TreeContents와 동일 이슈. 동일한 방식으로 정리하세요.
-import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; @@ - const [showToast, setShowToast] = useState(false); + const [showToast, setShowToast] = useState(false); + const timerRef = useRef<number | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); @@ - setShowToast(true); - setTimeout(() => setShowToast(false), 2000); + setShowToast(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setShowToast(false), 2000);Also applies to: 18-31
20-32: 오류 피드백 부재mutate 실패 시 사용자 알림이 없습니다. 최소한 콘솔 로그 또는 에러 토스트 추가 권장.
mutate(jobTodoId, { onSuccess: () => { @@ }, - onSettled: () => setClickedId(null), + onError: () => { + console.error('내 할일 추가에 실패했습니다.'); + }, + onSettled: () => setClickedId(null), });
12-89: 중복 로직 추출 제안Sprout/Tree(그리고 Ready가 있다면 동일) 컴포넌트가 거의 동일합니다. 공통 리스트 컴포넌트/훅으로 추출하면 유지보수성이 크게 좋아집니다.
7-13: SproutContentProps에서 미사용jobIdprop 제거 및 호출부 정리
SproutContent 내부에서jobId가 사용되지 않으므로, 인터페이스 정의(jobId)와 DetailTab의<SproutContent jobId={…} />전달 부분을 함께 삭제해주세요.src/hook/floating/useFloatingPopular.ts (2)
9-15: 토큰 미존재 시 즉시 오류 throw — UX 관점에서 게스트 데이터로 폴백 고려로그인 훅에서 토큰이 없을 때 게스트 엔드포인트로 폴백하면 모달 일관성이 좋아질 수 있습니다.
- if (!token) { - throw new Error('토큰이 없습니다.'); - } + if (!token) { + // 로그인 전용 훅이라면 throw 유지, UX 개선이 목적이면 게스트 데이터로 폴백: + return NoLoginFloatingPopular(); + }
16-29: 에러 로깅만 있고 재시도/캐시 정책 없음실시간이라도 짧은 staleTime(예: 5~10초)과 오류 재시도(1회) 정도는 UX에 유리합니다. 필요 시 options에서 기본값 제공을 고려하세요.
예:
- useQuery<FloatingPopularProps[]>({ + useQuery<FloatingPopularProps[]>({ queryKey: ['FloatingPopular', 'login'], queryFn: FloatingPopular, + staleTime: 10_000, + retry: 1, ...options, });동일 변경을 게스트 훅에도 적용.
Also applies to: 41-52
src/pages/community/components/CommunityRightSide.tsx (2)
106-110: 로딩 스피너 표시 조건 부정확
isLoading && isFetching && items.length > 0는 거의 충족되지 않아 스피너가 안 보일 수 있습니다. 추가 페이지 로딩에 스피너를 보여주려면isFetching && items.length > 0가 적절합니다.- {isLoading && isFetching && items.length > 0 && ( + {isFetching && items.length > 0 && ( <div className="fixed inset-0 z-50 flex items-center justify-center"> <LoadingSpinner /> </div> )}
86-101: 드롭다운 옵션을 div 대신 button으로 — 접근성/키보드 내비게이션 개선역할이 명확한 button 사용을 권장합니다. 스크린리더/키보드 접근성이 좋아집니다.
- {sortOptions.map((option) => ( - <div - key={option} - onClick={() => handleSelect(option)} - className={`w-full cursor-pointer px-5 py-6 text-left ${ + {sortOptions.map((option) => ( + <button + type="button" + key={option} + onClick={() => handleSelect(option)} + className={`w-full px-5 py-6 text-left ${ sort === option ? 'text-purple-500 font-B01-SB' : 'text-gray-400 font-B01-M' }`} > {option} - </div> + </button> ))}src/pages/community/components/CommunityContents.tsx (4)
9-18: 타입 중복 제거: 단일 소스 사용 권장동일한 CommunityItem 타입이
src/hook/community/query/useCommunityGetTodo.ts에도 존재합니다. 타입 드리프트 방지를 위해 해당 모듈에서 타입을 import해 사용하는 편이 안전합니다.-import { useState, useMemo } from 'react'; +import { useState, useMemo } from 'react'; +import type { CommunityItem } from '@hook/community/query/useCommunityGetTodo'; @@ -type CommunityItem = { - id: number; - name: string; - level: string; - imageUrl?: string; - dDay?: string; - description: string; - saveCount: number; - isSaved: boolean; -}; +// 기존 선언 제거: 공용 타입 사용
26-26: 레벨 정규화 보강(후행 '단계' 제거 및 공백 트림)데이터가 '씨앗단계'처럼 공백 없이 올 수도 있어 정규식을 쓰는 편이 견고합니다.
-const normalizeLevel = (s: string) => s.replace(' 단계', ''); +const normalizeLevel = (s: string) => s.replace(/\s*단계$/, '').trim();
36-38: 토스트 표시 순서/클린업 및 위치 보정
- 토스트 메시지 설정 순서가 분기마다 달라 가독성이 떨어집니다. 한 가지 순서로 통일해 주세요.
- setTimeout 클린업이 없어 빠르게 토글 시 타이머가 누적될 수 있습니다.
- 토스트 컨테이너가 수평 중앙에 정렬되지 않습니다(
items-center는 flex 컨테이너에서만 동작).-import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useEffect } from 'react'; @@ - const [toastMessage, setToastMessage] = useState(''); + const [toastMessage, setToastMessage] = useState(''); + const toastTimerRef = useRef<number | null>(null); + useEffect(() => { + return () => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + }; + }, []); @@ - setToastMessage('할일이 취소되었습니다.'); - setShowToast(true); - setTimeout(() => setShowToast(false), 2500); + setToastMessage('할일이 취소되었습니다.'); + setShowToast(true); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + toastTimerRef.current = window.setTimeout(() => setShowToast(false), 2500); @@ - setShowToast(true); setAdded((prev) => ({ ...prev, [id]: true })); setToastMessage('할일이 추가되었습니다.'); - setTimeout(() => { - setShowToast(false); - }, 2500); + setShowToast(true); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + toastTimerRef.current = window.setTimeout(() => setShowToast(false), 2500); @@ - {showToast && ( - <div className="fixed top-[100px] z-50 items-center"> + {showToast && ( + <div className="fixed left-1/2 top-[100px] z-50 -translate-x-1/2 transform"> <ToastModalAlso applies to: 71-78, 174-181
79-93: 로딩 오버레이 접근성전체 화면 고정 오버레이는 포커스를 가로챌 수 있습니다. 스크린리더용 라이브 영역이나 aria-busy 등을 고려해 주세요.
- <div className="fixed inset-0 z-50 flex items-center justify-center"> + <div className="fixed inset-0 z-50 flex items-center justify-center" role="status" aria-live="polite" aria-busy="true"> <LoadingSpinner /> </div>src/pages/community/components/CommunityDropdown.tsx (4)
24-26: useDropdown의 selected 미사용
useDropdown이 제공하는selected상태는 사용하지 않고, 로컬selected를 별도로 관리합니다. 혼선을 줄이기 위해 훅에서 selected를 노출하지 않거나 이 컴포넌트에서 일관되게 하나만 사용하세요.
31-41: 외부 value 동기화 로직 단순화
value !== options[0]일 때만 반영하는 조건은 부모 제어를 어렵게 합니다. API 초기화가 끝난 후에는 외부 value가 존재하면 그대로 반영하는 단순 규칙이 좋습니다.- useEffect(() => { - if (!initializedFromApi) return; // API 초기 반영 전에는 옵션/외부값으로 덮어쓰지 않음 - // 부모가 초기 표시용으로 전달한 첫 옵션 값은 무시하고, 실제 변경만 반영 - if (value && value !== (options[0] as T)) { - setSelected(value); - return; - } - if (!userSelected) { - setSelected((prev) => prev || (options[0] ?? '')); - } - }, [value, options, userSelected, initializedFromApi]); + useEffect(() => { + if (!initializedFromApi) return; + if (value !== undefined && value !== selected) { + setSelected(value as T | ''); + return; + } + if (!userSelected) { + setSelected((prev) => prev || (options[0] ?? '')); + } + }, [value, options, userSelected, initializedFromApi, selected]);
69-79: 접근성: 토글은 button 요소 사용 및 ARIA 속성 추가div에 onClick 대신 button을 사용하고, 확장 상태를 알려주세요.
- <div className="flex flex-row items-center gap-[18px]" onClick={toggle}> - <div className="text-purple-500 font-T02-B"> {label} </div> - <DropDownIcon + <button + type="button" + aria-haspopup="listbox" + aria-expanded={isOpen} + onClick={toggle} + className="flex flex-row items-center gap-[18px]" + > + <div className="text-purple-500 font-T02-B">{label}</div> + <DropDownIcon className={clsx( 'h-[30px] w-[30px] cursor-pointer text-[#A1A6B5] transition-transform duration-200', isOpen && 'rotate-180' )} /> - </div> + </button>
81-110: 리스트/옵션 역할 지정 및 키보드 내비게이션 준비간단히 role만 지정해도 보조공학 호환성이 향상됩니다.
- {isOpen && ( - <ul + {isOpen && ( + <ul + role="listbox" className={clsx( 'absolute left-0 top-full z-10 mt-[14px] w-max min-w-[200px] rounded-2xl border bg-white shadow-shadow4', shouldScroll ? 'max-h-[540px] overflow-y-auto' : 'overflow-visible' )} > - {options.map((opt) => ( + {options.map((opt) => ( <li key={opt} + role="option" + aria-selected={selected === opt} onClick={() => { setSelected(opt); setUserSelected(true); setSelectedJob({ name: String(opt), id: findJobIdByName(String(opt)), }); onSelect(opt); toggle(); }}
|
|
||
| const Divider = ({ className }: DividerProps) => { | ||
| return <div className={`h-px w-full bg-gray-300 ${className ?? ''}`} />; | ||
| return <div className={`h-px w-full ${className ?? ''}`} />; |
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.
💡 Verification agent
🧩 Analysis chain
기본 배경색 제거로 인한 UI 회귀 가능성 점검
이제 className을 넘기지 않으면 선이 보이지 않을 수 있습니다. 사용처 중 className 미지정 사례를 확인해 주세요.
원한다면 기본 색을 얇게 유지하고, 명시 시 오버라이드하는 방안도 있습니다.
-return <div className={`h-px w-full ${className ?? ''}`} />;
+return <div className={`h-px w-full ${className ?? 'bg-gray-200'}`} />;🏁 Script executed:
#!/bin/bash
# Divider 사용처에서 className 누락 여부를 확인
rg -nP --type=ts --type=tsx -C2 '<Divider\b(?![^>]*className=)' || trueLength of output: 100
rg 명령이 tsx 파일 타입을 인식하지 못해 검색이 실패한 것으로 보입니다. glob 패턴을 사용해 .ts/.tsx 파일을 대상으로 다시 검색합니다.
🏁 Script executed:
#!/bin/bash
rg -nP '<Divider\b(?![^>]*className=)' -n -C2 -g '*.ts' -g '*.tsx' || trueLength of output: 1549
Divider 컴포넌트 기본 색상 설정 필수
- className 미지정 시
<Divider />가 투명 선으로 렌더링되어 보이지 않습니다. 다음 5곳에서 확인 필요:- src/pages/login/LoginPage.tsx:48
- src/pages/otherTodo/components/ProfileCard.tsx:63
- src/pages/home/components/DreamerCard.tsx:73
- src/common/Footer.tsx:14
- src/common/Footer.tsx:23
- className이 없을 때 기본 색상을 적용하거나, 각 사용처에 명시적으로 클래스 지정
-return <div className={`h-px w-full ${className ?? ''}`} />;
+return <div className={`h-px w-full ${className ?? 'bg-gray-200'}`} />;📝 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.
| return <div className={`h-px w-full ${className ?? ''}`} />; | |
| export const Divider: React.FC<DividerProps> = ({ className }) => { | |
| return <div className={`h-px w-full ${className ?? 'bg-gray-200'}`} />; | |
| }; |
🤖 Prompt for AI Agents
In src/common/Divider.tsx around line 6, the Divider renders transparent when no
className is passed; make it apply a sensible default color class (e.g.
bg-gray-200 or bg-neutral-200) when className is undefined while preserving any
provided classes. Change the component to use the provided className if present,
otherwise fall back to the default color class; then check the five usage sites
listed in the comment (src/pages/login/LoginPage.tsx:48,
src/pages/otherTodo/components/ProfileCard.tsx:63,
src/pages/home/components/DreamerCard.tsx:73, src/common/Footer.tsx:14 and :23)
and remove redundant default classes or leave explicit overrides as needed.
| const handleFloatingButtonClick = () => { | ||
| // Amplitude 이벤트 - 플로팅 버튼 클릭 | ||
| if (typeof window !== 'undefined' && 'amplitude' in window && window.amplitude) { | ||
| (window as any).amplitude.track('todo_create', { | ||
| source_method: 'floating', | ||
| source_page: window.location.pathname, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| console.log('Amplitude event sent: todo_create_start (floating)'); | ||
| } | ||
|
|
||
| setIsModal(true); | ||
| }; |
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.
버튼 내부 중첩 onClick으로 닫기 불가 + ESLint(any) 발생
텍스트 div(onClick)와 버튼(onClick)이 중첩되어 클릭 시 항상 최종적으로 열림(setIsModal(true))으로 귀결됩니다. 또한 (window as any)로 인해 ESLint 에러가 납니다. 열기 시에만 이벤트 트래킹하고, 토글 로직으로 단순화하며 any 캐스트를 제거하세요.
- const handleFloatingButtonClick = () => {
- // Amplitude 이벤트 - 플로팅 버튼 클릭
- if (typeof window !== 'undefined' && 'amplitude' in window && window.amplitude) {
- (window as any).amplitude.track('todo_create', {
- source_method: 'floating',
- source_page: window.location.pathname,
- timestamp: new Date().toISOString(),
- });
- console.log('Amplitude event sent: todo_create_start (floating)');
- }
-
- setIsModal(true);
- };
+ const handleFloatingButtonClick = () => {
+ const opening = !isModal;
+ if (opening && typeof window !== 'undefined' && window.amplitude) {
+ window.amplitude.track('todo_create', {
+ source_method: 'floating',
+ source_page: window.location.pathname,
+ timestamp: new Date().toISOString(),
+ });
+ console.log('Amplitude event sent: todo_create (floating)');
+ }
+ setIsModal((prev) => !prev);
+ };
@@
- <button
+ <button
+ type="button"
className="fixed bottom-[50px] right-[80px] flex h-[102px] items-center gap-[10px] rounded-[28px] bg-purple-500 px-[30px] shadow-shadow4 transition-colors hover:bg-purple-600"
onClick={handleFloatingButtonClick}
>
- <div
- className="cursor-pointer text-white font-T02-B"
- onClick={() => setIsModal(false)}
- >
+ <div className="cursor-pointer text-white font-T02-B">
{isModal ? '할일 추가 닫기' : '바로 할일 추가'}
</div>Also applies to: 37-37, 41-46
🧰 Tools
🪛 ESLint
[error] 24-24: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents
In src/common/FloatingButton.tsx around lines 21-33 (also apply same changes at
37 and 41-46): the current nested onClick handlers cause clicks to always open
the modal and the code uses (window as any) which triggers ESLint any errors;
change the click handling to a single toggle using setIsModal(prev => { const
next = !prev; if (next) { const amp = (window as Window & { amplitude?: {
track?: (event: string, props?: Record<string, any>) => void } }).amplitude;
amp?.track('todo_create', { source_method: 'floating', source_page:
window.location.pathname, timestamp: new Date().toISOString() });
console.log('Amplitude event sent: todo_create_start (floating)'); } return
next; }); remove the nested onClick on the inner div (or stopPropagation) so a
single handler controls open/close, and replace any casts with a typed Window
assertion and optional chaining for amplitude.
| } = useFloatingPopular({ | ||
| enabled: isLoggedIn, | ||
| queryKey: [], | ||
| }); |
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.
중요: 옵션으로 queryKey를 덮어써 캐시 키가 비어 충돌합니다
useFloatingPopular/useNoLoginFloatingPopular 내부에서 queryKey를 지정한 뒤 ...options로 덮어쓰고 있어, 여기서 빈 배열을 전달하면 캐시 키가 []로 고정됩니다. 이는 서로 다른 쿼리가 같은 캐시를 공유하거나 경고를 유발할 수 있습니다. 옵션에서 queryKey를 제거하세요.
} = useFloatingPopular({
enabled: isLoggedIn,
- queryKey: [],
});
...
} = useNoLoginFloatingPopular({
enabled: !isLoggedIn,
- queryKey: [],
});Also applies to: 42-45
🤖 Prompt for AI Agents
In src/common/modal/FloatingModal.tsx around lines 33-36 (and similarly lines
42-45), the call to useFloatingPopular/useNoLoginFloatingPopular is passing
queryKey: [] which overwrites the hook's internal cache key and fixes it to an
empty array; remove the queryKey property from the options object (i.e., stop
supplying queryKey: [] or undefined) so the hook's internally defined queryKey
is used, leaving the rest of the options/spread intact.
| const res = await api.get('/v1/community/todos', { | ||
| params: { jobName, level, sort, page, size }, | ||
| ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), | ||
| }); | ||
| const result = res.data?.data; | ||
|
|
||
| return { | ||
| content: result.content, | ||
| number: result.number, | ||
| size: result.size, | ||
| first: result.first, | ||
| last: result.last, | ||
| empty: result.empty, | ||
| numberOfElements: result.numberOfElements, | ||
| pageable: { pageNumber: result.pageable?.pageNumber ?? page }, | ||
| sort: result.sort, | ||
| }; | ||
| }; |
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.
🛠️ Refactor suggestion
런타임 형태 검증과 방어적 체크 부재 — 응답 파싱 추가 제안
result가 null/shape 변경 시 NPE 위험이 있습니다. Zod 파싱과 명시적 에러를 추가하세요.
원하시면 해당 스키마를 별도 파일로 분리해 드리겠습니다.
import api from '@hook/api';
+import { z } from 'zod';
@@
- const res = await api.get('/v1/community/todos', {
+ const res = await api.get('/v1/community/todos', {
params: { jobName, level, sort, page, size },
...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
});
- const result = res.data?.data;
-
- return {
- content: result.content,
- number: result.number,
- size: result.size,
- first: result.first,
- last: result.last,
- empty: result.empty,
- numberOfElements: result.numberOfElements,
- pageable: { pageNumber: result.pageable?.pageNumber ?? page },
- sort: result.sort,
- };
+ const result = res.data?.data;
+ if (!result) {
+ throw new Error('Invalid response: data is missing');
+ }
+
+ const CommunityItemSchema = z
+ .object({
+ id: z.number().int().positive(),
+ name: z.string(),
+ level: z.string(),
+ imageUrl: z.string().url().or(z.literal('')).optional(),
+ dDay: z.string().optional(),
+ description: z.string(),
+ saveCount: z.number().int().nonnegative(),
+ isSaved: z.boolean(),
+ })
+ .passthrough();
+
+ const CommunityGetTodoResponseSchema = z
+ .object({
+ content: z.array(CommunityItemSchema),
+ number: z.number().int().nonnegative(),
+ size: z.number().int().positive(),
+ first: z.boolean(),
+ last: z.boolean(),
+ empty: z.boolean(),
+ numberOfElements: z.number().int().nonnegative(),
+ pageable: z.object({ pageNumber: z.number().int().nonnegative() }).partial(),
+ sort: z.object({ empty: z.boolean(), sorted: z.boolean(), unsorted: z.boolean() }),
+ })
+ .passthrough();
+
+ const parsed = CommunityGetTodoResponseSchema.parse(result);
+ return {
+ ...parsed,
+ pageable: { pageNumber: parsed.pageable?.pageNumber ?? page },
+ };📝 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 res = await api.get('/v1/community/todos', { | |
| params: { jobName, level, sort, page, size }, | |
| ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), | |
| }); | |
| const result = res.data?.data; | |
| return { | |
| content: result.content, | |
| number: result.number, | |
| size: result.size, | |
| first: result.first, | |
| last: result.last, | |
| empty: result.empty, | |
| numberOfElements: result.numberOfElements, | |
| pageable: { pageNumber: result.pageable?.pageNumber ?? page }, | |
| sort: result.sort, | |
| }; | |
| }; | |
| // src/hook/community/query/useCommunityGetTodo.ts | |
| import api from '@hook/api'; | |
| import { z } from 'zod'; | |
| export const useCommunityGetTodo = async ({ | |
| jobName, | |
| level, | |
| sort, | |
| page, | |
| size, | |
| token, | |
| }: { | |
| jobName: string; | |
| level: string; | |
| sort: string; | |
| page: number; | |
| size: number; | |
| token?: string; | |
| }) => { | |
| const res = await api.get('/v1/community/todos', { | |
| params: { jobName, level, sort, page, size }, | |
| ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), | |
| }); | |
| const result = res.data?.data; | |
| if (!result) { | |
| throw new Error('Invalid response: data is missing'); | |
| } | |
| const CommunityItemSchema = z | |
| .object({ | |
| id: z.number().int().positive(), | |
| name: z.string(), | |
| level: z.string(), | |
| imageUrl: z.string().url().or(z.literal('')).optional(), | |
| dDay: z.string().optional(), | |
| description: z.string(), | |
| saveCount: z.number().int().nonnegative(), | |
| isSaved: z.boolean(), | |
| }) | |
| .passthrough(); | |
| const CommunityGetTodoResponseSchema = z | |
| .object({ | |
| content: z.array(CommunityItemSchema), | |
| number: z.number().int().nonnegative(), | |
| size: z.number().int().positive(), | |
| first: z.boolean(), | |
| last: z.boolean(), | |
| empty: z.boolean(), | |
| numberOfElements: z.number().int().nonnegative(), | |
| pageable: z | |
| .object({ pageNumber: z.number().int().nonnegative() }) | |
| .partial(), | |
| sort: z | |
| .object({ | |
| empty: z.boolean(), | |
| sorted: z.boolean(), | |
| unsorted: z.boolean(), | |
| }), | |
| }) | |
| .passthrough(); | |
| const parsed = CommunityGetTodoResponseSchema.parse(result); | |
| return { | |
| ...parsed, | |
| pageable: { pageNumber: parsed.pageable?.pageNumber ?? page }, | |
| }; | |
| }; |
| return useQuery({ | ||
| queryKey: ['community', isLoggedIn], | ||
| queryFn: async () => { |
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.
💡 Verification agent
❓ Verification inconclusive
queryKey에 로그인 여부만 포함 → 계정 전환 캐시 오염 가능성
동일한 “로그인 상태(true)”에서 계정을 바꾸면 key가 동일해 캐시 재사용됩니다. 사용자 식별자(예: userId) 또는 토큰 버전 키를 포함해 주세요. 토큰 원문 자체는 포함하지 마세요.
예) queryKey: ['community', { authed: !!token, userId }]
queryKey에 userId 포함 필수
현재 queryKey: ['community', isLoggedIn]만 사용 → 로그인 유지 상태에서 계정 전환 시 캐시가 재사용되어 잘못된 데이터 노출 위험.
queryKey에 userId(또는 토큰 버전 등 식별자) 추가하세요. 토큰 원문은 제외합니다.
예)
queryKey: ['community', { authed: !!token, userId }]🤖 Prompt for AI Agents
In src/hook/community/query/useGetCommunityQuery.ts around lines 8-10, the
queryKey currently omits a user identifier which can cause cache reuse across
account switches; update the queryKey to include a non-sensitive user identifier
(e.g. userId or token version) rather than the raw token. Retrieve userId from
the auth context or a safe selector, and set queryKey like ['community', {
authed: !!token, userId }] (or similar) so cache is partitioned per-user; do not
store or log the raw token.
| interface TreeContentsProps { | ||
| jobId: number; | ||
| data?: JobTodoCategoryProps; | ||
| } | ||
|
|
||
| const TreeContents = ({ data }: TreeContentsProps) => { | ||
| const treeData = data?.jobTodos ?? []; |
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.
💡 Verification agent
🧩 Analysis chain
불필요 prop 정의 제거 검토
TreeContentsProps의 jobId는 컴포넌트에서 사용되지 않습니다. prop API 간소화 또는 실제 사용(로그/추적)에 활용 중인지 확인 바랍니다.
필요 시 <TreeContents ...> 호출부에서 jobId 전달 여부를 점검해 주세요:
🏁 Script executed:
#!/bin/bash
rg -n -C2 '<TreeContents\b' --type=tsxLength of output: 68
다음 스크립트를 실행해 <TreeContents> 호출부를 .ts 및 .tsx 파일에서 찾아 jobId 전달 여부를 확인해주세요.
🏁 Script executed:
#!/bin/bash
rg -n -C2 '<TreeContents\b' -g '**/*.{ts,tsx}'Length of output: 435
불필요한 jobId prop 제거
TreeContents 컴포넌트에서 사용되지 않는 jobId prop을 인터페이스와 호출부에서 삭제해주세요.
- src/pages/jobDetail/components/TabContent/TreeContents.tsx: TreeContentsProps 인터페이스에서 jobId 제거
- src/pages/jobDetail/components/DetailTab.tsx (60행):
<TreeContents>호출부에서 jobId={…} 제거
🤖 Prompt for AI Agents
In src/pages/jobDetail/components/TabContent/TreeContents.tsx around lines 7 to
13, remove the unused jobId property from the TreeContentsProps interface and
from the component signature/usage (only keep data?: JobTodoCategoryProps and
use treeData = data?.jobTodos ?? []); then update the caller in
src/pages/jobDetail/components/DetailTab.tsx at line 60 to stop passing jobId
(remove jobId={...} from the <TreeContents> JSX).
| icon={<Info className="h-6 w-6 text-white" />} | ||
| text="내 할일 목록에 추가 되었습니다" | ||
| width="w-[350px]" | ||
| /> |
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.
토스트 문구 띄어쓰기 오류(사용자 노출 텍스트)
"추가 되었습니다" → "추가되었습니다"가 맞습니다. 사용자 신뢰도에 영향을 줄 수 있어 즉시 수정 권장.
- text="내 할일 목록에 추가 되었습니다"
+ text="내 할일 목록에 추가되었습니다"📝 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.
| icon={<Info className="h-6 w-6 text-white" />} | |
| text="내 할일 목록에 추가 되었습니다" | |
| width="w-[350px]" | |
| /> | |
| icon={<Info className="h-6 w-6 text-white" />} | |
| text="내 할일 목록에 추가되었습니다" | |
| width="w-[350px]" | |
| /> |
🤖 Prompt for AI Agents
In src/pages/jobDetail/components/TabContent/TreeContents.tsx around lines 81 to
84, the toast message contains incorrect spacing ("내 할일 목록에 추가 되었습니다"); update
the displayed text to the correct Korean spacing "내 할일 목록에 추가되었습니다" so the
user-facing string is fixed; ensure only that string literal is changed and run
a quick lint/format check to catch similar issues.
| physical={jobDetail?.strong.physical || ''} | ||
| stress={jobDetail?.strong.stress || ''} | ||
| relationship={jobDetail?.strong.relationship || ''} |
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.
Optional chaining 누락으로 NPE 가능
jobDetail?.strong.physical에서 strong가 undefined면 런타임 오류가 납니다. ?.를 추가하세요.
- <WorkStrong
- physical={jobDetail?.strong.physical || ''}
- stress={jobDetail?.strong.stress || ''}
- relationship={jobDetail?.strong.relationship || ''}
- />
+ <WorkStrong
+ physical={jobDetail?.strong?.physical || ''}
+ stress={jobDetail?.strong?.stress || ''}
+ relationship={jobDetail?.strong?.relationship || ''}
+ />📝 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.
| physical={jobDetail?.strong.physical || ''} | |
| stress={jobDetail?.strong.stress || ''} | |
| relationship={jobDetail?.strong.relationship || ''} | |
| <WorkStrong | |
| physical={jobDetail?.strong?.physical || ''} | |
| stress={jobDetail?.strong?.stress || ''} | |
| relationship={jobDetail?.strong?.relationship || ''} | |
| /> |
🤖 Prompt for AI Agents
In src/pages/jobDetail/JobInfo.tsx around lines 99 to 101, the code accesses
jobDetail?.strong.physical, .stress, and .relationship which can throw if strong
is undefined; update these accesses to use optional chaining on the strong
property (i.e., use jobDetail?.strong?.physical, jobDetail?.strong?.stress, and
jobDetail?.strong?.relationship) so they safely evaluate to undefined/empty
string instead of causing an NPE.
|
|
||
| setSelectedJob({ id: pendingJob.id, name: pendingJob.name }); | ||
| setHasEverSelectedJob(true); | ||
| queryClient.invalidateQueries({ queryKey: ['mypageInfo'] }); |
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.
💡 Verification agent
🧩 Analysis chain
react-query invalidate 키 불일치로 캐시 무효화 실패
useGetInfo의 queryKey는 ['Mypage']인데 여기서는 ['mypageInfo']를 무효화하고 있어 업데이트가 반영되지 않습니다.
- queryClient.invalidateQueries({ queryKey: ['mypageInfo'] });
+ queryClient.invalidateQueries({ queryKey: ['Mypage'] });🏁 Script executed:
#!/bin/bash
# Mypage 쿼리키/무효화 키 불일치 탐색
rg -n "queryKey:\s*\[\s*'Mypage'\s*\]" --type=ts -C2
rg -n "invalidateQueries\(\{\s*queryKey:\s*\[\s*'mypageInfo'\s*\]\s*\}\)" --type=ts -C2Length of output: 1771
invalidateQueries queryKey 수정 (JobSelect.tsx:81)
queryKey: ['mypageInfo'] → ['Mypage']로 변경해야 useMypageQuery 캐시가 정상 무효화됩니다.
- queryClient.invalidateQueries({ queryKey: ['mypageInfo'] });
+ queryClient.invalidateQueries({ queryKey: ['Mypage'] });📝 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.
| queryClient.invalidateQueries({ queryKey: ['mypageInfo'] }); | |
| queryClient.invalidateQueries({ queryKey: ['Mypage'] }); |
🤖 Prompt for AI Agents
In src/pages/jobSelect/JobSelect.tsx around line 81, the call to
queryClient.invalidateQueries uses the wrong queryKey (queryKey: ['mypageInfo'])
so the useMypageQuery cache isn't being invalidated; change the queryKey to
['Mypage'] to match the useMypageQuery key so the cache is properly invalidated
after the mutation.
| if (window.amplitude) { | ||
| window.amplitude.track('signup_success', { | ||
| email: email, | ||
| login_id: loginId, | ||
| nickname: data.nickname, | ||
| gender: selectedGender ?? gender, | ||
| birth_date: data.date, | ||
| region_code: regionCode, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| console.log('Amplitude event sent: signup_success'); | ||
| } |
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.
Amplitude로 PII(이메일/로그인ID/생년월일) 전송 — 개인정보/컴플라이언스 리스크
민감 정보는 식별/결합 위험이 큽니다. 사용자 동의/정책 정비 전에는 전송을 제거하거나 최소화하세요.
- window.amplitude.track('signup_success', {
- email: email,
- login_id: loginId,
- nickname: data.nickname,
- gender: selectedGender ?? gender,
- birth_date: data.date,
- region_code: regionCode,
- timestamp: new Date().toISOString(),
- });
+ window.amplitude.track('signup_success', {
+ nickname: data.nickname,
+ gender: selectedGender ?? gender,
+ region_code: regionCode,
+ timestamp: new Date().toISOString(),
+ });필요 시 해시(예: SHA-256)로 비가역화 후 전송을 검토하시되, 그 자체도 정책 검토가 필요합니다.
📝 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.
| if (window.amplitude) { | |
| window.amplitude.track('signup_success', { | |
| email: email, | |
| login_id: loginId, | |
| nickname: data.nickname, | |
| gender: selectedGender ?? gender, | |
| birth_date: data.date, | |
| region_code: regionCode, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| console.log('Amplitude event sent: signup_success'); | |
| } | |
| if (window.amplitude) { | |
| window.amplitude.track('signup_success', { | |
| nickname: data.nickname, | |
| gender: selectedGender ?? gender, | |
| region_code: regionCode, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| console.log('Amplitude event sent: signup_success'); | |
| } |
🚀 풀 리퀘스트 제안
close #303
close #298
close #325
Summary by CodeRabbit
New Features
UI
Analytics