Conversation
AI 요약하기 버튼과 워크스페이스 동그란 버튼에 primary-300 → primary-500 그라데이션 적용
- 4개 섹션으로 재구성: 플랫폼 연동 → 통합 대시보드 → 캠페인 관리 → 팀 협업 - 피처 카드 설명 제거 및 체크리스트 스타일로 변경 - 뱃지 디자인 소프트 tint 스타일로 변경 (bg-primary-100 + text-primary-500) - 목업 컬럼 비율 조정 (3/5 → 1/2) - GuideOverviewChart: 플랫폼 드롭다운 토글 추가 및 색상 동적 변경 - GuidePlatform: 플랫폼 연동 카드 UI로 교체 - GuideWorkspace: 팀원 초대 모달 디자인 기반 신규 목업 추가
[Feature/#263] 타임라인 생성 모달 UI 구현
[Feature/#265] 타임라인 페이지 레이아웃 조립 및 디자인 수정 및 UI 구현
- GuideOverviewChart: 플랫폼별 series 맵 도입, 색상 토큰 적용, legend 및 sr-only 동적화 - GuidePlatform: 목업 연동하기 버튼 비활성 스타일로 교체 - GuideWorkspace: 목업 루트에 aria-hidden 추가 - LandingFeatures: 인라인 gradient style을 Tailwind bg-linear-to-br 유틸리티로 교체
[Refactor/#230] Landing Page 플랫폼 변경 및 요소 추가
rounded-[8px]→rounded-lg, rounded-[6px]→rounded-md, h-[28px]→h-7, w-[55px]→w-13.75, rounded-[12px]→rounded-xl, h-[52px]→h-13, w-[4px]→w-1, h-[30px]→h-7.5
토글 없이 항상 펼쳐진 형태로 바꾸고, 카드형 테두리 대신 얇은 구분선으로 스타일 단순화
- GuidePlatform/Timeline/Workspace/OverviewChart 높이를 h-85(340px)로 통일해 텍스트 대비 목업이 과도하게 커 보이던 문제 개선 - OverviewChart y축 그리드라인을 6개에서 3개로 줄여 축소된 높이에서도 답답하지 않게 조정 - GuidePlatform에 NAVER 연동 완료 예시 상태(Badge)를 추가해 미연동 카드들과 시각적으로 구분
세로 스크롤 구조를 유지하면서, 화면 우측에 01~04 진행 인디케이터를 sticky하게 표시해 현재 보고 있는 스텝을 안내
"Google·Meta 파트너 서비스로..." 대신 매체별로 따로 확인하던 불편함을 강조하는 문구로 교체
- LandingHeader: sticky → fixed, 스크롤 비례 진행도(progress 0-1)로 배경 투명도·backdrop-blur·보더·텍스트·로고 색상을 실시간 연속 보간 (ease-out quadratic 곡선, RAF 쓰로틀) - LandingHeader: 로고를 컬러/흰색 두 이미지 교차 페이드로 전환 - LandingHeader: Bigin 스타일 [로고+네비] / [로그인·CTA] 2그룹 레이아웃 및 max-w-7xl 컨테이너 좌우 여백 적용 - LandingHero: id="hero" 추가(IntersectionObserver 타깃), 콘텐츠 높이 min-h-dvh로 확장해 투명 헤더 오버랩 공간 확보
- 네비게이션 링크에 rounded-full 호버 필 배경 추가 (progress 기반 pillBg로 dark/light 모드 자동 대응) - 텍스트 opacity-90 → group-hover:opacity-100으로 살짝 밝아지는 효과 - 회원가입 버튼을 진한 파란 배경에서 네비게이션 필 스타일로 변경 (기본 pillBg 표시, 호버 시 동일 레이어 중첩으로 자연스럽게 진해짐) - 회원가입 텍스트 font-label(14px/600)로 크기·굵기 조정 - CTA 그룹 gap gap-2 md:gap-3 → gap-1로 통일
📝 WalkthroughWalkthrough이번 PR은 타임라인 CRUD 및 UI(그리드, 바, 성과 패널, 생성/수정 모달), 네이버/메타 플랫폼 연동 및 해제 플로우(암호화 전송, 재연동, OAuth 콜백), 랜딩 페이지 리디자인(헤더 스크롤 효과, 가이드 섹션, FAQ/푸터 재구성), 공통 에러/404 처리 및 라우팅, 그리고 DropdownMenu·WorkspaceSwitcher·useCampaignGroup의 데이터 훅 전환을 포함합니다. Changes타임라인 기능
플랫폼 연동(Naver/Meta) 및 해제
랜딩 페이지 리디자인
공통 에러 처리 및 라우팅
공유 컴포넌트/훅 리팩터링
Estimated code review effort: 5 (Critical) | ~150 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant TimelinePage as Timeline
participant useRequestTimelineSummary
participant TimelineAPI as requestTimelineSummary
participant useTimelineDetail
User->>TimelinePage: 막대 클릭 후 요약 요청
TimelinePage->>useRequestTimelineSummary: mutate(timelineId)
useRequestTimelineSummary->>TimelineAPI: requestTimelineSummary(orgId, timelineId)
TimelineAPI-->>useRequestTimelineSummary: 202 Accepted
TimelinePage->>useTimelineDetail: 폴링(재조회)
useTimelineDetail-->>TimelinePage: aiSummary 반영된 상세 데이터
sequenceDiagram
participant Browser
participant MetaOAuthResultPage
participant useMetaOAuthReturn
participant ReactQueryCache as QueryClient
Browser->>MetaOAuthResultPage: /oauth2/meta/result?status=...
MetaOAuthResultPage->>useMetaOAuthReturn: 마운트 시 호출
useMetaOAuthReturn->>useMetaOAuthReturn: status/error 검증 및 토스트 결정
useMetaOAuthReturn->>ReactQueryCache: invalidateQueries(platform.connections)
useMetaOAuthReturn->>Browser: /integrations로 replace 리다이렉트
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ 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: 11
🧹 Nitpick comments (10)
src/routes/AuthRoutes.tsx (1)
10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
MetaOAuthResultPagedirect import vs lazy loading 일관성
RedirectPage를 비롯한 다른 auth 페이지들은loadable(lazy(...))패턴을 사용하는데,MetaOAuthResultPage만 direct import로 되어 있습니다. 컴포넌트가 작아 번들 영향은 미미하지만, 패턴 일관성을 위해loadable적용을 고려해 보세요. 다만 OAuth 리다이렉트 안정성을 위해 의도적으로 즉시 로드한 것이라면 현상태로 유지해도 좋습니다.♻️ 제안: lazy loading 적용
-import MetaOAuthResultPage from "`@/pages/integration/MetaOAuthResultPage`"; +const MetaOAuthResultPage = loadable( + lazy(() => import("`@/pages/integration/MetaOAuthResultPage`")), + <AuthFormSkeleton />, +);Also applies to: 68-71
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/AuthRoutes.tsx` at line 10, `AuthRoutes`의 `MetaOAuthResultPage`만 direct import로 되어 있어 다른 auth 페이지들의 `loadable(lazy(...))` 패턴과 불일치합니다. `RedirectPage` 등 기존 라우팅 방식과 맞추려면 `MetaOAuthResultPage`도 동일하게 lazy loading으로 전환하고, 라우트 정의에서 이를 사용하는 흐름을 함께 정리하세요. 단, OAuth 리다이렉트 시 즉시 로드가 의도된 경우라면 `AuthRoutes`의 해당 import와 라우트 연결을 그대로 유지해도 됩니다.src/components/timeline/TimelineCreateModal.tsx (1)
215-245: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueController render에서
field를 사용하지 않고 외부watch값을 직접 참조하고 있습니다.
Controller의renderprop은field객체를 통해 폼 상태와 동기화하는 것이 권장 패턴입니다. 현재는watch("metrics")로 외부에서 값을 읽고setValue로만 갱신하고 있어Controller의 역할이 중복됩니다.watch+setValue만 사용하거나,field를 활용하는 쪽으로 통일하면 의도가 더 명확해집니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/timeline/TimelineCreateModal.tsx` around lines 215 - 245, The metrics picker inside TimelineCreateModal’s Controller is mixing Controller with external watch/setValue state, and the render prop ignores the field object. Refactor the metrics selection to use one pattern consistently: either remove Controller and keep watch("metrics") plus setValue via toggleMetric, or switch the render callback to use field.value and field.onChange for syncing selected metrics. Keep the change localized to TimelineCreateModal and the metrics toggle UI.src/api/timeline/timeline.ts (1)
34-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
validateStatus로직이 중복입니다.
status === 201(또는202)는 이미status >= 200 && status < 300범위에 포함되므로, 명시적인 equality check는 axios 기본 동작과 동일한 결과를 냅니다. 의도한 것이 아니라면 불필요한 코드이므로 제거를 권장합니다.♻️ 제안하는 수정
export const createTimeline = async ( orgId: number, body: ITimelineUpsertRequest, ): Promise<ITimelineMutationResponse> => { const { data } = await axiosInstance.post< ICommonResponse<ITimelineMutationResponse> - >(`/api/org/${orgId}/timeline`, body, { - validateStatus: (status) => - status === 201 || (status >= 200 && status < 300), - }); + >(`/api/org/${orgId}/timeline`, body); return data.data; };export const requestTimelineSummary = async ( orgId: number, timelineId: number, ): Promise<TTimelineEmptyResponse> => { const { data } = await axiosInstance.post< ICommonResponse<TTimelineEmptyResponse> - >( - `/api/org/${orgId}/timeline/${timelineId}/summary`, - {}, - { - validateStatus: (status) => - status === 202 || (status >= 200 && status < 300), - }, - ); + >(`/api/org/${orgId}/timeline/${timelineId}/summary`, {}); return data.data; };Also applies to: 70-86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/api/timeline/timeline.ts` around lines 34 - 45, The `createTimeline` request in `timeline.ts` has redundant `validateStatus` logic: the explicit `status === 201` check is already covered by `status >= 200 && status < 300`. Simplify the `axiosInstance.post` options by removing the equality branch and keep only the standard 2xx range check unless there is a separate special-case requirement; apply the same cleanup to the matching timeline request code referenced by the comment.src/hooks/timeline/useTimelineDetail.ts (1)
16-20: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
enabled옵션이 호출자에 의해 override될 수 있습니다.
{ enabled: orgId != null && timelineId != null, ...options }순서로 spread되어 있어, 호출자가options에enabled를 전달하면 내부 null 가드가 우회됩니다. 이 경우orgId!,timelineId!non-null assertion이 런타임 에러를 유발할 수 있습니다. 현재 호출처에서는 문제가 없지만, 방어적으로enabled를 병합하는 것이 안전합니다.🛡️ 제안하는 수정
return useCoreQuery( QUERY_KEYS.timeline.detail(orgId, timelineId), () => getTimelineDetail(orgId!, timelineId!), - { enabled: orgId != null && timelineId != null, ...options }, + { + ...options, + enabled: + (orgId != null && timelineId != null) && + (options?.enabled ?? true), + }, );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/hooks/timeline/useTimelineDetail.ts` around lines 16 - 20, The `useTimelineDetail` query options currently let caller-provided `options.enabled` override the internal null guard, which can bypass the safety check before `getTimelineDetail(orgId!, timelineId!)` runs. Update the options handling in `useTimelineDetail` so `enabled` is merged defensively, preserving `orgId != null && timelineId != null` as a required condition even when `options` includes `enabled`, while keeping the rest of the caller options intact.src/utils/timeline/period.ts (1)
132-133: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
resolveVisiblePeriodMONTH 케이스의 불필요한 삼항 연산자
periodIndex === 0 ? formatMonthLabel(start) : formatMonthLabel(start)에서 두 분기가 동일한 값을 반환합니다. 삼항 연산자 없이formatMonthLabel(start)만 호출하면 됩니다.♻️ 제안 수정
return { start, end, - periodLabel: - periodIndex === 0 ? formatMonthLabel(start) : formatMonthLabel(start), + periodLabel: formatMonthLabel(start), };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/timeline/period.ts` around lines 132 - 133, The `resolveVisiblePeriod` MONTH branch contains a redundant ternary where both outcomes call `formatMonthLabel(start)`. Simplify the `periodLabel` assignment in `resolveVisiblePeriod` by removing the `periodIndex === 0 ? ... : ...` condition and using `formatMonthLabel(start)` directly, keeping the rest of the period calculation unchanged.src/utils/timeline/timeline.ts (1)
8-18: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win날짜 필드에 날짜 형식 검증 추가를 권장
dataFieldSchema가z.string().trim().min(1)만 수행하여 빈 문자열만 막고,"YYYY-MM-DD"형식인지 검증하지 않습니다. 현재<Input type="date">에서만 입력되므로 실제 버그는 아니지만, 폼 값이 프로그래밍 방식으로 설정될 경우.refine()의 문자열 비교(startDate <= endDate)가 잘못된 결과를 낼 수 있습니다. Zod 4의z.iso.date()또는 regex 검증 추가를 권장합니다.🛡️ 제안 수정 (regex 검증 추가)
-const dataFieldSchema = z.string().trim().min(1, "날짜를 선택해 주세요"); +const dataFieldSchema = z + .string() + .trim() + .min(1, "날짜를 선택해 주세요") + .regex(/^\d{4}-\d{2}-\d{2}$/, "올바른 날짜 형식이 아닙니다");🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/timeline/timeline.ts` around lines 8 - 18, `dataFieldSchema` only checks for non-empty strings, so it should also validate that the value is a real `YYYY-MM-DD` date before `timelineCreateSchema` uses it. Update the shared date schema in `src/utils/timeline/timeline.ts` to include ISO date validation (prefer `z.iso.date()` in Zod 4, or an equivalent regex/refine check) while keeping the existing empty-value message. This ensures `startDate` and `endDate` stay well-formed when values are set programmatically and that the `refine` comparison in `timelineCreateSchema` works correctly.src/components/landing/GuideOverviewChart.tsx (1)
44-47: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueSPLIT_INDEX를 공통 상수로 묶어주세요.
GuideOverviewChart.tsx:44-47의SPLIT_INDEX는overviewChart.ts의 기준값과 같은 값이지만 중복 선언돼 있어, 이후 분기점이 바뀌면 annotation과 series 기준이 따로 어긋날 수 있습니다.overviewChart.ts에서 export해서 두 곳이 같은 값을 쓰게 맞추면 됩니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/landing/GuideOverviewChart.tsx` around lines 44 - 47, `GuideOverviewChart`에서 `SPLIT_INDEX`를 로컬 상수로 중복 선언하지 말고, `overviewChart`에서 export한 공통 기준값을 import해서 사용하도록 바꾸세요. 이렇게 하면 annotation과 series가 같은 분기점을 참조하게 되어 값 변경 시 서로 어긋나지 않습니다. `SPLIT_INDEX`와 `SINGLE_SERIES_DATA`가 있는 `GuideOverviewChart.tsx`와 기준값을 정의한 `overviewChart.ts`를 함께 수정해 일관된 상수를 공유하게 하세요.src/components/landing/LandingHeader.tsx (2)
12-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
scrollToSection함수가LandingHero.tsx와 중복 정의되어 있으며 구현도 다릅니다.
LandingHeader는scrollend이벤트 기반,LandingHero는 이중requestAnimationFrame기반으로 포커스 시점을 처리하고 있어 일관성이 깨집니다. 공통 유틸로 추출하여 동일한 동작을 보장하도록 권장합니다.♻️ 공통 유틸 추출 제안
// src/utils/landing/scrollToSection.ts export function scrollToSection(id: string) { const el = document.getElementById(id); if (!(el instanceof HTMLElement)) return; const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; if (prefersReducedMotion) { el.scrollIntoView({ behavior: "auto" }); el.focus({ preventScroll: true }); return; } el.scrollIntoView({ behavior: "smooth" }); if ("onscrollend" in window) { window.addEventListener( "scrollend", () => el.focus({ preventScroll: true }), { once: true }, ); } else { setTimeout(() => el.focus({ preventScroll: true }), 600); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/landing/LandingHeader.tsx` around lines 12 - 39, `scrollToSection` is duplicated in `LandingHeader` and `LandingHero` with different focus timing, so extract the shared scrolling/focus behavior into a single utility and use it from both components. Keep the existing reduced-motion handling and `scrollend`/timeout fallback in one place so both `LandingHeader` and `LandingHero` behave consistently and future changes only need to be made once.
70-87: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win인라인 스타일의 임의 RGB 색상 값을
@theme토큰 기반으로 전환을 권장합니다.
textColorBase,pillBg,headerStyle에서rgb(255,255,255),rgb(55,65,81),rgba(221,227,240,...)등 원시 RGB 값을 직접 계산하고 있습니다. 코딩 가이드라인에 따르면src/styles/tokens.css의@theme토큰만 사용해야 하며, 임의 색상은 금지됩니다. Tailwind v4의@theme토큰은 CSS 변수(var(--color-text-body),var(--color-surface-100),var(--color-surface-400)등)로 노출되므로,color-mix()를 활용해 동적 보간을 토큰 기반으로 수행할 수 있습니다.As per coding guidelines:
**/*.{ts,tsx}: "Use only@themetokens fromsrc/styles/tokens.cssfor colors. Forbidden: token renaming, arbitrary colors."♻️ `color-mix()` 기반 제안
- const textColorBase = `rgb(${Math.round(255 - 200 * eased)},${Math.round(255 - 190 * eased)},${Math.round(255 - 174 * eased)})`; + const textColorBase = `color-mix(in srgb, var(--color-surface-100) ${(1 - eased) * 100}%, var(--color-text-body) ${eased * 100}%)`; - const pillBg = `rgba(${Math.round(255 - 200 * eased)},${Math.round(255 - 190 * eased)},${Math.round(255 - 174 * eased)},${(0.12 - 0.05 * eased).toFixed(3)})`; + const pillBg = `color-mix(in srgb, var(--color-surface-100) ${(1 - eased) * 100}%, var(--color-text-body) ${eased * 100}%) / ${(0.12 - 0.05 * eased).toFixed(3)})`; const headerStyle = { - backgroundColor: `rgba(255,255,255,${(eased * 0.82).toFixed(3)})`, + backgroundColor: `color-mix(in srgb, var(--color-surface-100) ${eased * 82}%, transparent)`, backdropFilter: `blur(${(eased * 12).toFixed(1)}px)`, WebkitBackdropFilter: `blur(${(eased * 12).toFixed(1)}px)`, - borderBottomColor: `rgba(221,227,240,${eased.toFixed(3)})`, + borderBottomColor: `color-mix(in srgb, var(--color-surface-400) ${eased * 100}%, transparent)`, } as CSSProperties;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/landing/LandingHeader.tsx` around lines 70 - 87, The inline color math in LandingHeader should stop using hardcoded RGB/RGBA values and switch to the existing `@theme` token palette. Update the style logic in the LandingHeader component (textColorBase, pillBg, and headerStyle) to derive colors from token-backed CSS variables such as var(--color-text-body) and var(--color-surface-*) instead of raw numeric channels. If dynamic blending is still needed, use token-based color-mix() so the header animation remains consistent with the design system and coding guidelines.Source: Coding guidelines
src/components/landing/LandingFooter.tsx (1)
118-118: 📐 Maintainability & Code Quality | 🔵 TrivialTODO: 대표자명 및 사업자등록번호 추가 건에 대해 도움을 드릴 수 있습니다.
{/* TODO: 대표자명, 사업자등록번호 등 확정 시 아래 줄에 추가 */}주석이 남아 있습니다. 사업자 정보가 확정되면 이 자리에 추가해야 합니다. 데이터 구조 설계나 하드코딩된 값의 외부화가 필요하시면 도움을 드릴 수 있습니다. 새 이슈를 열어 추적하시겠습니까?🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/landing/LandingFooter.tsx` at line 118, The LandingFooter component still contains a placeholder TODO comment for the company representative and business registration number; update this footer block to render the finalized business information in place of the comment, using the existing LandingFooter markup and any related constants or props if available. If the values are not yet available in the component, move them into a clearly defined source and wire them into the footer instead of leaving the TODO behind.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/integration/NaverConnectModal.tsx`:
- Around line 55-68: In NaverConnectModal, the reconnect path only resets the
form when initialCustomerId exists, which leaves Customer ID empty but locked
when mode is "reconnect" and the ID is missing. Update the useEffect reset logic
so reconnect still initializes the form even without initialCustomerId, and
adjust the Customer ID input’s readOnly condition to depend on both mode ===
"reconnect" and the presence of initialCustomerId. This should be applied in
NaverConnectModal around the reset behavior and the Customer ID field props.
In `@src/components/landing/LandingHeader.tsx`:
- Line 91: The Tailwind height utility in LandingHeader is using the wrong
arbitrary value syntax, so update the className on LandingHeader to use the
bracket form for the CSS variable fallback, matching the pattern used elsewhere
like LandingFAQ. Replace the current h-(--landing-header-height,64px) usage with
the proper height utility that maps to var(--landing-header-height,64px) so the
header height is applied consistently.
In `@src/components/timeline/TimelineCreateModal.tsx`:
- Around line 265-289: The dropdown trigger in TimelineCreateModal is focusable
via tabIndex but lacks keyboard activation, so add a key handling path on the
trigger element rendered by the trigger prop to open it with Enter and Space.
Update the same div that currently renders comparisonLabel and ChevronIcon to
handle keyboard events consistently with mouse clicks, while preserving the
existing open state behavior and disabled styling tied to isPending and
errors.comparisonPeriodType.
In `@src/hooks/ads/useCampaignGroup.ts`:
- Around line 84-93: The group creation flow only invalidates
QUERY_KEYS.campaign.list(orgId), but useCampaignGroup also reads
platform-specific campaign lists via QUERY_KEYS.campaign.platformList(orgId,
"GOOGLE" | "NAVER" | "META"). Update the invalidateKeys in this hook so campaign
platform lists are invalidated together with the group list, ensuring connected
campaigns are removed from the selectable options on the next visit.
In `@src/hooks/timeline/useDeleteTimeline.ts`:
- Line 5: The import in useDeleteTimeline should use the project alias instead
of a relative path to match the coding guideline and keep consistency with
useTimelineList. Update the useCoreMutation import to reference the shared
customQuery module via the "`@/hooks/customQuery`" alias rather than
"../customQuery", and keep the change limited to the import statement in
useDeleteTimeline.
In `@src/hooks/timeline/useRequestTimelineSummary.ts`:
- Line 5: The import in useRequestTimelineSummary should use the project alias
instead of a relative path. Update the useCoreMutation import in this hook to
reference the canonical `@/hooks/customQuery` path so it matches the repo-wide
import convention and keeps the timeline hook aligned with the rest of the
codebase.
In `@src/hooks/timeline/useUpdateTimeline.ts`:
- Line 7: The import in useUpdateTimeline should use the required `@/` alias
instead of a relative path. Update the useCoreMutation import to reference the
shared customQuery module through the alias path (matching the project’s import
conventions) so the hook stays consistent with the rest of the codebase.
In `@src/pages/dashboard/overview/sections/OverviewKpiSection.tsx`:
- Around line 38-58: The KPI ErrorBoundary around the `OverviewKpiSection` grid
is missing `resetKeys`, so `MetricErrorFallback` can stay stuck even after
`kpis` are refetched. Update the `ErrorBoundary` in `OverviewKpiSection` to use
`resetKeys` based on the `kpis` prop, matching the auto-reset behavior used in
`OverviewBudgetSection` and `OverviewDashboard` platform sections. This will let
the boundary recover automatically when the KPI data changes instead of
requiring a manual retry.
In `@src/utils/integration/encryptAesCbcBase64.ts`:
- Around line 32-56: The encryptAesCbcBase64 helper is using build-time client
env values for AES secret/IV, which exposes sensitive material in the browser
bundle. Update encryptAesCbcBase64 to stop relying on VITE_NAVER_AES_SECRET and
the fixed VITE_NAVER_AES_IV in client-side code; move this encryption to a
server-side path or redesign it so a fresh random IV is generated per call and
transmitted alongside the ciphertext. Keep the changes localized around
encryptAesCbcBase64 and parseKeyOrIv usage so the browser no longer contains
reusable secret material or a static CBC IV.
In `@src/utils/timeline/buildTimelineSummaryPanel.ts`:
- Line 9: The import in buildTimelineSummaryPanel should use the project’s "`@/`”
alias instead of a relative path. Update the existing formatDot import to
reference "`@/utils/timeline/period`" so the module follows the codebase import
convention and remains easy to locate alongside other timeline utilities.
In `@src/utils/timeline/period.ts`:
- Around line 67-77: formatWeekLabel has redundant if/else branches that both
return the same string, so the month-aware branch is dead code. Update
formatWeekLabel to use different formatting in the same-month case versus
cross-month case, likely by showing the month only once when start.getMonth()
=== end.getMonth() and keeping the full start/end month format otherwise. Use
the existing formatWeekLabel, MONTH, and date extraction logic to make the
intended week label concise and distinct across the two cases.
---
Nitpick comments:
In `@src/api/timeline/timeline.ts`:
- Around line 34-45: The `createTimeline` request in `timeline.ts` has redundant
`validateStatus` logic: the explicit `status === 201` check is already covered
by `status >= 200 && status < 300`. Simplify the `axiosInstance.post` options by
removing the equality branch and keep only the standard 2xx range check unless
there is a separate special-case requirement; apply the same cleanup to the
matching timeline request code referenced by the comment.
In `@src/components/landing/GuideOverviewChart.tsx`:
- Around line 44-47: `GuideOverviewChart`에서 `SPLIT_INDEX`를 로컬 상수로 중복 선언하지 말고,
`overviewChart`에서 export한 공통 기준값을 import해서 사용하도록 바꾸세요. 이렇게 하면 annotation과
series가 같은 분기점을 참조하게 되어 값 변경 시 서로 어긋나지 않습니다. `SPLIT_INDEX`와
`SINGLE_SERIES_DATA`가 있는 `GuideOverviewChart.tsx`와 기준값을 정의한 `overviewChart.ts`를
함께 수정해 일관된 상수를 공유하게 하세요.
In `@src/components/landing/LandingFooter.tsx`:
- Line 118: The LandingFooter component still contains a placeholder TODO
comment for the company representative and business registration number; update
this footer block to render the finalized business information in place of the
comment, using the existing LandingFooter markup and any related constants or
props if available. If the values are not yet available in the component, move
them into a clearly defined source and wire them into the footer instead of
leaving the TODO behind.
In `@src/components/landing/LandingHeader.tsx`:
- Around line 12-39: `scrollToSection` is duplicated in `LandingHeader` and
`LandingHero` with different focus timing, so extract the shared scrolling/focus
behavior into a single utility and use it from both components. Keep the
existing reduced-motion handling and `scrollend`/timeout fallback in one place
so both `LandingHeader` and `LandingHero` behave consistently and future changes
only need to be made once.
- Around line 70-87: The inline color math in LandingHeader should stop using
hardcoded RGB/RGBA values and switch to the existing `@theme` token palette.
Update the style logic in the LandingHeader component (textColorBase, pillBg,
and headerStyle) to derive colors from token-backed CSS variables such as
var(--color-text-body) and var(--color-surface-*) instead of raw numeric
channels. If dynamic blending is still needed, use token-based color-mix() so
the header animation remains consistent with the design system and coding
guidelines.
In `@src/components/timeline/TimelineCreateModal.tsx`:
- Around line 215-245: The metrics picker inside TimelineCreateModal’s
Controller is mixing Controller with external watch/setValue state, and the
render prop ignores the field object. Refactor the metrics selection to use one
pattern consistently: either remove Controller and keep watch("metrics") plus
setValue via toggleMetric, or switch the render callback to use field.value and
field.onChange for syncing selected metrics. Keep the change localized to
TimelineCreateModal and the metrics toggle UI.
In `@src/hooks/timeline/useTimelineDetail.ts`:
- Around line 16-20: The `useTimelineDetail` query options currently let
caller-provided `options.enabled` override the internal null guard, which can
bypass the safety check before `getTimelineDetail(orgId!, timelineId!)` runs.
Update the options handling in `useTimelineDetail` so `enabled` is merged
defensively, preserving `orgId != null && timelineId != null` as a required
condition even when `options` includes `enabled`, while keeping the rest of the
caller options intact.
In `@src/routes/AuthRoutes.tsx`:
- Line 10: `AuthRoutes`의 `MetaOAuthResultPage`만 direct import로 되어 있어 다른 auth
페이지들의 `loadable(lazy(...))` 패턴과 불일치합니다. `RedirectPage` 등 기존 라우팅 방식과 맞추려면
`MetaOAuthResultPage`도 동일하게 lazy loading으로 전환하고, 라우트 정의에서 이를 사용하는 흐름을 함께 정리하세요.
단, OAuth 리다이렉트 시 즉시 로드가 의도된 경우라면 `AuthRoutes`의 해당 import와 라우트 연결을 그대로 유지해도 됩니다.
In `@src/utils/timeline/period.ts`:
- Around line 132-133: The `resolveVisiblePeriod` MONTH branch contains a
redundant ternary where both outcomes call `formatMonthLabel(start)`. Simplify
the `periodLabel` assignment in `resolveVisiblePeriod` by removing the
`periodIndex === 0 ? ... : ...` condition and using `formatMonthLabel(start)`
directly, keeping the rest of the period calculation unchanged.
In `@src/utils/timeline/timeline.ts`:
- Around line 8-18: `dataFieldSchema` only checks for non-empty strings, so it
should also validate that the value is a real `YYYY-MM-DD` date before
`timelineCreateSchema` uses it. Update the shared date schema in
`src/utils/timeline/timeline.ts` to include ISO date validation (prefer
`z.iso.date()` in Zod 4, or an equivalent regex/refine check) while keeping the
existing empty-value message. This ensures `startDate` and `endDate` stay
well-formed when values are set programmatically and that the `refine`
comparison in `timelineCreateSchema` works correctly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0c033ab5-29de-48f1-8ae0-9152eaa7a685
⛔ Files ignored due to path filters (17)
.storybook/main.tsis excluded by none and included by none.storybook/preview.tsxis excluded by none and included by nonepackage-lock.jsonis excluded by!**/package-lock.jsonand included by nonepackage.jsonis excluded by none and included by nonepnpm-lock.yamlis excluded by!**/pnpm-lock.yaml,!pnpm-lock.yamland included by nonesrc/assets/icon/ai/ai-요약버튼.svgis excluded by!**/*.svgand included bysrc/**src/assets/icon/ai/sparkle-circle.svgis excluded by!**/*.svgand included bysrc/**src/assets/icon/common/lightbulb.svgis excluded by!**/*.svgand included bysrc/**src/assets/icon/common/plus.svgis excluded by!**/*.svgand included bysrc/**src/assets/logo/social-logo/plain/google_ads.pngis excluded by!**/*.pngand included bysrc/**src/assets/logo/social-logo/plain/meta.svgis excluded by!**/*.svgand included bysrc/**src/assets/logo/social-logo/wordmark/kakao-wordmark.svgis excluded by!**/*.svgand included bysrc/**src/assets/logo/social-logo/wordmark/naver-wordmark.pngis excluded by!**/*.pngand included bysrc/**src/assets/mockup/footer_bg.jpgis excluded by!**/*.jpgand included bysrc/**src/assets/mockup/iOS app dock.pngis excluded by!**/*.pngand included bysrc/**src/assets/mockup/iPad Air mockup.pngis excluded by!**/*.pngand included bysrc/**src/assets/mockup/optimized/timeline_dashboard.jpgis excluded by!**/*.jpgand included bysrc/**
📒 Files selected for processing (70)
src/api/integration/naver.tssrc/api/integration/platformAccounts.tssrc/api/timeline/timeline.tssrc/components/common/ComingSoonPlaceholder.tsxsrc/components/common/dropdownmenu/DropdownMenu.tsxsrc/components/common/error/ChartErrorFallback.stories.tsxsrc/components/common/error/ChartErrorFallback.tsxsrc/components/common/error/ErrorBoundary.tsxsrc/components/common/error/ErrorLayout.tsxsrc/components/common/error/MetricErrorFallback.stories.tsxsrc/components/common/error/MetricErrorFallback.tsxsrc/components/integration/NaverConnectModal.tsxsrc/components/integration/PlatformDisconnectModal.tsxsrc/components/landing/GuideOverviewChart.tsxsrc/components/landing/GuidePlatform.tsxsrc/components/landing/GuideTimeline.tsxsrc/components/landing/GuideWorkspace.tsxsrc/components/landing/LandingFAQ.tsxsrc/components/landing/LandingFeatures.tsxsrc/components/landing/LandingFooter.tsxsrc/components/landing/LandingGuide.tsxsrc/components/landing/LandingHeader.tsxsrc/components/landing/LandingHero.tsxsrc/components/landing/LandingMultiDevice.tsxsrc/components/sidebar/WorkspaceSwitcher.tsxsrc/components/timeline/TimelineAxis.tsxsrc/components/timeline/TimelineBar.tsxsrc/components/timeline/TimelineCreateModal.stories.tsxsrc/components/timeline/TimelineCreateModal.tsxsrc/components/timeline/TimelineEmptyState.tsxsrc/components/timeline/TimelineGrid.tsxsrc/components/timeline/TimelinePerformancePanel.tsxsrc/components/timeline/TimelinePeriodSelector.tsxsrc/components/timeline/TimelineStatusLegend.tsxsrc/components/timeline/skeleton/TimelineSkeleton.tsxsrc/constants/landing/guide.tssrc/constants/landing/overviewChart.tssrc/constants/timeline/formOptions.tssrc/constants/timeline/layout.tssrc/constants/timeline/statusStyle.tssrc/hooks/ads/useCampaignGroup.tssrc/hooks/integration/useIntegrationOAuthReturn.tssrc/hooks/integration/useMetaOAuthReturn.tssrc/hooks/timeline/useCreateTimeline.tssrc/hooks/timeline/useDeleteTimeline.tssrc/hooks/timeline/useRequestTimelineSummary.tssrc/hooks/timeline/useTimelineDetail.tssrc/hooks/timeline/useTimelineList.tssrc/hooks/timeline/useUpdateTimeline.tssrc/lib/animation.tssrc/lib/queryKeys.tssrc/pages/common/Error.tsxsrc/pages/common/NotFound.tsxsrc/pages/dashboard/overview/OverviewDashboard.tsxsrc/pages/dashboard/overview/sections/OverviewBudgetSection.tsxsrc/pages/dashboard/overview/sections/OverviewKpiSection.tsxsrc/pages/dashboard/timeline/Timeline.tsxsrc/pages/integration/MetaOAuthResultPage.tsxsrc/pages/integration/PlatformIntegrationsPage.tsxsrc/pages/landing/LandingPage.tsxsrc/routes/AuthRoutes.tsxsrc/routes/Router.tsxsrc/types/timeline/api.tssrc/types/timeline/timeline.mock.tssrc/types/timeline/ui.tssrc/utils/integration/encryptAesCbcBase64.tssrc/utils/timeline/buildTimelineGrid.tssrc/utils/timeline/buildTimelineSummaryPanel.tssrc/utils/timeline/period.tssrc/utils/timeline/timeline.ts
💤 Files with no reviewable changes (2)
- src/components/landing/LandingMultiDevice.tsx
- src/pages/landing/LandingPage.tsx
| useEffect(() => { | ||
| if (!isOpen) { | ||
| reset(); | ||
| return; | ||
| } | ||
| }, [isOpen, reset]); | ||
|
|
||
| const connectMutation = useMutation< | ||
| void, | ||
| IApiErrorResponse, | ||
| TNaverConnectFormValues | ||
| >({ | ||
| mutationFn: (body) => connectNaverAccount(orgId, body), | ||
| onSuccess: () => { | ||
| toast.success("네이버 광고 계정을 연동했습니다."); | ||
| reset(); | ||
| onClose(); | ||
| void queryClient.invalidateQueries({ | ||
| queryKey: ["platform-connections", orgId], | ||
|
|
||
| if (mode === "reconnect" && initialCustomerId) { | ||
| reset({ | ||
| customerId: initialCustomerId, | ||
| apiKey: "", | ||
| secretKey: "", | ||
| }); | ||
| } | ||
| }, [isOpen, mode, initialCustomerId, reset]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
reconnect 모드에서 initialCustomerId가 없을 때 폼 리셋 및 readOnly 처리 누락
mode === "reconnect"이지만 initialCustomerId가 falsy인 경우, if 조건이 충족되지 않아 폼이 리셋되지 않습니다. 이때 readOnly={mode === "reconnect"}로 인해 고객 ID 입력이 잠기지만 값은 비어 있어, 사용자가 고객 ID를 입력할 수 없는 상태가 됩니다.
부모 컴포넌트에서 naverItem.externalAccountId가 undefined일 수 있으므로, 이 케이스가 발생할 수 있습니다.
🛠️ Proposed fix
if (mode === "reconnect" && initialCustomerId) {
reset({
customerId: initialCustomerId,
apiKey: "",
secretKey: "",
});
+ } else {
+ reset();
}그리고 readOnly 조건에 initialCustomerId 존재 여부를 추가하여, 값이 없을 때는 직접 입력할 수 있도록 변경:
- readOnly={mode === "reconnect"}
+ readOnly={mode === "reconnect" && !!initialCustomerId}📝 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.
| useEffect(() => { | |
| if (!isOpen) { | |
| reset(); | |
| return; | |
| } | |
| }, [isOpen, reset]); | |
| const connectMutation = useMutation< | |
| void, | |
| IApiErrorResponse, | |
| TNaverConnectFormValues | |
| >({ | |
| mutationFn: (body) => connectNaverAccount(orgId, body), | |
| onSuccess: () => { | |
| toast.success("네이버 광고 계정을 연동했습니다."); | |
| reset(); | |
| onClose(); | |
| void queryClient.invalidateQueries({ | |
| queryKey: ["platform-connections", orgId], | |
| if (mode === "reconnect" && initialCustomerId) { | |
| reset({ | |
| customerId: initialCustomerId, | |
| apiKey: "", | |
| secretKey: "", | |
| }); | |
| } | |
| }, [isOpen, mode, initialCustomerId, reset]); | |
| useEffect(() => { | |
| if (!isOpen) { | |
| reset(); | |
| return; | |
| } | |
| if (mode === "reconnect" && initialCustomerId) { | |
| reset({ | |
| customerId: initialCustomerId, | |
| apiKey: "", | |
| secretKey: "", | |
| }); | |
| } else { | |
| reset(); | |
| } | |
| }, [isOpen, mode, initialCustomerId, reset]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/integration/NaverConnectModal.tsx` around lines 55 - 68, In
NaverConnectModal, the reconnect path only resets the form when
initialCustomerId exists, which leaves Customer ID empty but locked when mode is
"reconnect" and the ID is missing. Update the useEffect reset logic so reconnect
still initializes the form even without initialCustomerId, and adjust the
Customer ID input’s readOnly condition to depend on both mode === "reconnect"
and the presence of initialCustomerId. This should be applied in
NaverConnectModal around the reset behavior and the Customer ID field props.
| isScrolled | ||
| ? "bg-surface-100 shadow-Soft border-transparent" | ||
| : "bg-surface-100/80 backdrop-blur-xl border-surface-400" | ||
| className={`fixed top-0 left-0 right-0 z-50 border-b transition-shadow duration-300 h-(--landing-header-height,64px) ${ |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Tailwind v4 빌드 결과에서 h-(--landing-header-height,64px)가 올바른 CSS로 생성되는지 확인
rg -n "landing-header-height" src/components/landing/ --type=tsxRepository: WhereYouAd/WhereYouAd-Frontend
Length of output: 196
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## LandingHeader file\n'
sed -n '1,180p' src/components/landing/LandingHeader.tsx
printf '\n## FAQ file\n'
sed -n '1,180p' src/components/landing/LandingFAQ.tsx
printf '\n## Tailwind 관련 설정/참조\n'
git ls-files | rg 'tailwind|postcss|vite|css$|LandingHeader|LandingFAQ'Repository: WhereYouAd/WhereYouAd-Frontend
Length of output: 7673
🌐 Web query:
Tailwind CSS v4 h-(--foo,64px) var fallback syntax parentheses comma arbitrary values
💡 Result:
In Tailwind CSS v4, you can use parentheses () to reference CSS variables within arbitrary values, which serves as a cleaner alternative to the traditional var() syntax [1][2]. Regarding your specific query about the syntax h-(--foo,64px): 1. Parentheses Syntax: Tailwind v4 introduced this shorthand specifically for CSS variables [1]. For example, h-(--foo) automatically resolves to height: var(--foo). 2. Fallback Handling: While the parentheses syntax is designed to simplify referencing variables, it does not natively support the standard CSS comma-separated fallback syntax (e.g., var(--foo, 64px)) directly inside the parentheses in the same way var() does [1][2]. If you need to include a fallback value, you should continue using the standard square bracket notation with the full var() function [3]:
var() function arguments rather than as a separator or delimiter within the utility class [3]. For simple variable references without fallbacks, the parentheses shorthand remains the recommended, more concise choice [1][2].
Citations:
- 1: https://github.com/tailwindlabs/tailwindcss/blob/f369e22172dcda93ee526696aacc3e974918db47/CHANGELOG.md
- 2: https://josedacosta.github.io/tailwindcss-obfuscator/research/tailwind-v4-features-analysis
- 3: https://www.mushfiqweb.com/blog/on-tailwind-css-arbitrary-values
h-(--landing-header-height,64px)는 대괄호 구문으로 바꾸는 게 맞습니다.
Tailwind v4의 괄호 구문은 CSS 변수 참조용이라 fallback(,64px)을 섞으면 의도한 height: var(--landing-header-height,64px)로 처리되지 않을 수 있습니다. h-[var(--landing-header-height,64px)]처럼 적거나, LandingFAQ.tsx처럼 대괄호 구문으로 통일하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/landing/LandingHeader.tsx` at line 91, The Tailwind height
utility in LandingHeader is using the wrong arbitrary value syntax, so update
the className on LandingHeader to use the bracket form for the CSS variable
fallback, matching the pattern used elsewhere like LandingFAQ. Replace the
current h-(--landing-header-height,64px) usage with the proper height utility
that maps to var(--landing-header-height,64px) so the header height is applied
consistently.
| trigger={(open) => ( | ||
| <div | ||
| tabIndex={0} | ||
| className={twMerge( | ||
| "flex h-14 w-full cursor-pointer items-center justify-between rounded-2xl bg-surface-100 px-5 text-left font-body1 ring-1 ring-surface-400 transition-colors duration-200 ease-out outline-none", | ||
| "hover:bg-surface-200 hover:ring-surface-400", | ||
| "focus-visible:ring-2 focus-visible:ring-surface-400", | ||
| isPending && "cursor-not-allowed opacity-60", | ||
| errors.comparisonPeriodType | ||
| ? "ring-2 ring-info-red bg-info-red/5" | ||
| : "", | ||
| )} | ||
| > | ||
| <span className="min-w-0 flex-1 truncate text-text-title"> | ||
| {comparisonLabel} | ||
| </span> | ||
| <ChevronIcon | ||
| className={twMerge( | ||
| "h-4 w-4 shrink-0 text-text-muted transition-transform duration-200", | ||
| open ? "rotate-0" : "rotate-180", | ||
| )} | ||
| aria-hidden | ||
| /> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
드롭다운 트리거에 키보드 활성화 핸들러가 없습니다.
tabIndex={0}으로 포커스는 가능하지만 onKeyDown 핸들러가 없어 Enter/Space 키로 드롭다운을 열 수 없습니다. 접근성을 위해 키보드 활성화를 추가해 주세요.
♿ 제안: 키보드 핸들러 추가
<div
tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.currentTarget.click();
+ }
+ }}
className={twMerge(📝 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.
| trigger={(open) => ( | |
| <div | |
| tabIndex={0} | |
| className={twMerge( | |
| "flex h-14 w-full cursor-pointer items-center justify-between rounded-2xl bg-surface-100 px-5 text-left font-body1 ring-1 ring-surface-400 transition-colors duration-200 ease-out outline-none", | |
| "hover:bg-surface-200 hover:ring-surface-400", | |
| "focus-visible:ring-2 focus-visible:ring-surface-400", | |
| isPending && "cursor-not-allowed opacity-60", | |
| errors.comparisonPeriodType | |
| ? "ring-2 ring-info-red bg-info-red/5" | |
| : "", | |
| )} | |
| > | |
| <span className="min-w-0 flex-1 truncate text-text-title"> | |
| {comparisonLabel} | |
| </span> | |
| <ChevronIcon | |
| className={twMerge( | |
| "h-4 w-4 shrink-0 text-text-muted transition-transform duration-200", | |
| open ? "rotate-0" : "rotate-180", | |
| )} | |
| aria-hidden | |
| /> | |
| </div> | |
| )} | |
| trigger={(open) => ( | |
| <div | |
| tabIndex={0} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| e.currentTarget.click(); | |
| } | |
| }} | |
| className={twMerge( | |
| "flex h-14 w-full cursor-pointer items-center justify-between rounded-2xl bg-surface-100 px-5 text-left font-body1 ring-1 ring-surface-400 transition-colors duration-200 ease-out outline-none", | |
| "hover:bg-surface-200 hover:ring-surface-400", | |
| "focus-visible:ring-2 focus-visible:ring-surface-400", | |
| isPending && "cursor-not-allowed opacity-60", | |
| errors.comparisonPeriodType | |
| ? "ring-2 ring-info-red bg-info-red/5" | |
| : "", | |
| )} | |
| > | |
| <span className="min-w-0 flex-1 truncate text-text-title"> | |
| {comparisonLabel} | |
| </span> | |
| <ChevronIcon | |
| className={twMerge( | |
| "h-4 w-4 shrink-0 text-text-muted transition-transform duration-200", | |
| open ? "rotate-0" : "rotate-180", | |
| )} | |
| aria-hidden | |
| /> | |
| </div> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/timeline/TimelineCreateModal.tsx` around lines 265 - 289, The
dropdown trigger in TimelineCreateModal is focusable via tabIndex but lacks
keyboard activation, so add a key handling path on the trigger element rendered
by the trigger prop to open it with Enter and Space. Update the same div that
currently renders comparisonLabel and ChevronIcon to handle keyboard events
consistently with mouse clicks, while preserving the existing open state
behavior and disabled styling tied to isPending and errors.comparisonPeriodType.
Source: Path instructions
| { | ||
| invalidateKeys: [QUERY_KEYS.campaign.list(orgId)], | ||
| userOnSuccess: () => { | ||
| setIsSuccessModalOpen(true); | ||
| }, | ||
| userOnError: (error) => { | ||
| toast.error(error.message ?? "그룹 생성에 실패했습니다."); | ||
| }, | ||
| }, | ||
| }); | ||
| ); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# createCampaignGroup API 정의를 확인하여 그룹 생성 후 캠페인 상태 변경 여부를 검증합니다.
rg -n "createCampaignGroup" --type=ts -C5 src/api/ads/ads.ts
# 기존 코드에서 platformList 무효화 여부를 확인하기 위해 git 히스토리를 검색합니다.
git log --oneline -5 -- src/hooks/ads/useCampaignGroup.tsRepository: WhereYouAd/WhereYouAd-Frontend
Length of output: 781
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 관련 훅과 쿼리 키, 그리고 platformList / list의 데이터 소스를 확인합니다.
rg -n "QUERY_KEYS\.campaign\.(list|platformList)|platformList\(|campaign\.list\(" src -C 3
echo '---'
sed -n '1,220p' src/hooks/ads/useCampaignGroup.ts
echo '---'
sed -n '1,220p' src/api/ads/ads.tsRepository: WhereYouAd/WhereYouAd-Frontend
Length of output: 9391
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# platformList 사용처와 관련 mutation에서의 invalidateKeys를 확인합니다.
rg -n "platformList\(|invalidateKeys: \[.*campaign\.(list|platformList)|createCampaignGroup|update.*Campaign|delete.*Campaign|campaignIds" src -C 3
echo '--- useCampaignGroup related files ---'
sed -n '1,220p' src/hooks/ads/useCampaignGroup.ts
echo '--- query keys ---'
sed -n '1,220p' src/lib/queryKeys.tsRepository: WhereYouAd/WhereYouAd-Frontend
Length of output: 12990
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 캠페인 그룹 생성이 플랫폼별 목록의 의미와 실제로 충돌하는지 판단할 수 있도록
# 사용처, 타입 정의, 관련 화면을 더 확인합니다.
rg -n "useCampaignGroup|platformCampaigns|연결 가능한 캠페인|캠페인 그룹|createCampaignGroup|getPlatformCampaigns" src -C 3
echo '--- campaign types ---'
sed -n '1,220p' src/types/ads/campaign.ts
echo '--- useCampaignGroup UI ---'
sed -n '1,240p' src/pages/ads/group/CreateCampaignGroupPage.tsx 2>/dev/null || true
sed -n '1,240p' src/components/ads/group/* 2>/dev/null || trueRepository: WhereYouAd/WhereYouAd-Frontend
Length of output: 7257
그룹 생성 후 플랫폼별 캠페인 캐시도 함께 무효화해야 합니다.
invalidateKeys에는 QUERY_KEYS.campaign.list(orgId)만 들어 있습니다. 이 훅에서 읽는 QUERY_KEYS.campaign.platformList(orgId, "GOOGLE" | "NAVER" | "META")는 연결 가능한 캠페인 목록이라, 그룹 생성 후에는 함께 무효화해야 이미 연결된 캠페인이 다음 진입 시 옵션에 남지 않습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/ads/useCampaignGroup.ts` around lines 84 - 93, The group creation
flow only invalidates QUERY_KEYS.campaign.list(orgId), but useCampaignGroup also
reads platform-specific campaign lists via
QUERY_KEYS.campaign.platformList(orgId, "GOOGLE" | "NAVER" | "META"). Update the
invalidateKeys in this hook so campaign platform lists are invalidated together
with the group list, ensuring connected campaigns are removed from the
selectable options on the next visit.
|
|
||
| import type { IApiErrorResponse } from "@/types/common/common"; | ||
|
|
||
| import { useCoreMutation } from "../customQuery"; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
@/ alias 대신 상대 경로 import 사용
../customQuery 대신 @/hooks/customQuery를 사용해야 합니다. 코딩 가이드라인에서 모든 import에 @/ alias를 사용하도록 규정하고 있으며, 같은 계층의 useTimelineList.ts는 @/hooks/customQuery를 사용하고 있어 일관성이 깨집니다.
As per coding guidelines: "Use @/ alias for all imports."
🔧 제안 수정
-import { useCoreMutation } from "../customQuery";
+import { useCoreMutation } from "`@/hooks/customQuery`";📝 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.
| import { useCoreMutation } from "../customQuery"; | |
| import { useCoreMutation } from "`@/hooks/customQuery`"; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/timeline/useDeleteTimeline.ts` at line 5, The import in
useDeleteTimeline should use the project alias instead of a relative path to
match the coding guideline and keep consistency with useTimelineList. Update the
useCoreMutation import to reference the shared customQuery module via the
"`@/hooks/customQuery`" alias rather than "../customQuery", and keep the change
limited to the import statement in useDeleteTimeline.
Source: Coding guidelines
| import type { IApiErrorResponse } from "@/types/common/common"; | ||
| import type { IUpdateTimelineVariables } from "@/types/timeline/api"; | ||
|
|
||
| import { useCoreMutation } from "../customQuery"; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
@/ alias 대신 상대 경로 import가 사용되었습니다.
"../customQuery"는 "@/hooks/customQuery"로 변경해야 합니다. As per coding guidelines, 모든 import에 @/ alias를 사용해야 합니다.
♻️ 제안하는 수정
-import { useCoreMutation } from "../customQuery";
+import { useCoreMutation } from "`@/hooks/customQuery`";📝 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.
| import { useCoreMutation } from "../customQuery"; | |
| import { useCoreMutation } from "`@/hooks/customQuery`"; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/timeline/useUpdateTimeline.ts` at line 7, The import in
useUpdateTimeline should use the required `@/` alias instead of a relative path.
Update the useCoreMutation import to reference the shared customQuery module
through the alias path (matching the project’s import conventions) so the hook
stays consistent with the rest of the codebase.
Source: Coding guidelines
| <ErrorBoundary FallbackComponent={MetricErrorFallback}> | ||
| {isKpisError ? ( | ||
| <div className="flex items-center justify-center rounded-3xl border border-surface-100/40 bg-surface-100/80 py-8 font-body2 text-info-red"> | ||
| {kpisError?.message ?? | ||
| "지표 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} | ||
| </div> | ||
| ) : ( | ||
| <div className="grid min-w-0 grid-cols-4 gap-4 tablet:grid-cols-2 tablet:gap-4"> | ||
| {isKpisLoading | ||
| ? [0, 1, 2, 3].map((i) => <OverviewKpiCardSkeleton key={i} />) | ||
| : kpiList.map((kpi) => ( | ||
| <StatCard | ||
| key={`d-${kpi.title}`} | ||
| {...kpi} | ||
| className="gap-3 py-5" | ||
| compact | ||
| /> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </ErrorBoundary> |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
KPI ErrorBoundary에 resetKeys 추가를 권장합니다.
OverviewBudgetSection와 OverviewDashboard의 플랫폼 섹션에는 resetKeys가 설정되어 있어 데이터 갱신 시 에러 바운더리가 자동 리셋됩니다. 하지만 KPI 그리드를 감싼 ErrorBoundary에는 resetKeys가 없어, kpis 데이터가 리패치되어도 MetricErrorFallback가 유지됩니다. 사용자가 매번 "다시 시도" 버튼을 눌러야 합니다.
kpis가 컴포넌트 props로 사용 가능하므로 resetKeys={[kpis]} 추가만으로 자동 복구가 가능합니다.
💡 제안: resetKeys 추가
- <ErrorBoundary FallbackComponent={MetricErrorFallback}>
+ <ErrorBoundary FallbackComponent={MetricErrorFallback} resetKeys={[kpis]}>
{isKpisError ? (📝 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.
| <ErrorBoundary FallbackComponent={MetricErrorFallback}> | |
| {isKpisError ? ( | |
| <div className="flex items-center justify-center rounded-3xl border border-surface-100/40 bg-surface-100/80 py-8 font-body2 text-info-red"> | |
| {kpisError?.message ?? | |
| "지표 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} | |
| </div> | |
| ) : ( | |
| <div className="grid min-w-0 grid-cols-4 gap-4 tablet:grid-cols-2 tablet:gap-4"> | |
| {isKpisLoading | |
| ? [0, 1, 2, 3].map((i) => <OverviewKpiCardSkeleton key={i} />) | |
| : kpiList.map((kpi) => ( | |
| <StatCard | |
| key={`d-${kpi.title}`} | |
| {...kpi} | |
| className="gap-3 py-5" | |
| compact | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </ErrorBoundary> | |
| <ErrorBoundary FallbackComponent={MetricErrorFallback} resetKeys={[kpis]}> | |
| {isKpisError ? ( | |
| <div className="flex items-center justify-center rounded-3xl border border-surface-100/40 bg-surface-100/80 py-8 font-body2 text-info-red"> | |
| {kpisError?.message ?? | |
| "지표 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} | |
| </div> | |
| ) : ( | |
| <div className="grid min-w-0 grid-cols-4 gap-4 tablet:grid-cols-2 tablet:gap-4"> | |
| {isKpisLoading | |
| ? [0, 1, 2, 3].map((i) => <OverviewKpiCardSkeleton key={i} />) | |
| : kpiList.map((kpi) => ( | |
| <StatCard | |
| key={`d-${kpi.title}`} | |
| {...kpi} | |
| className="gap-3 py-5" | |
| compact | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </ErrorBoundary> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/pages/dashboard/overview/sections/OverviewKpiSection.tsx` around lines 38
- 58, The KPI ErrorBoundary around the `OverviewKpiSection` grid is missing
`resetKeys`, so `MetricErrorFallback` can stay stuck even after `kpis` are
refetched. Update the `ErrorBoundary` in `OverviewKpiSection` to use `resetKeys`
based on the `kpis` prop, matching the auto-reset behavior used in
`OverviewBudgetSection` and `OverviewDashboard` platform sections. This will let
the boundary recover automatically when the KPI data changes instead of
requiring a manual retry.
| export function encryptAesCbcBase64(plainText: string): string { | ||
| const secret = import.meta.env.VITE_NAVER_AES_SECRET as string | undefined; | ||
| const iv = import.meta.env.VITE_NAVER_AES_IV as string | undefined; | ||
|
|
||
| if (!secret || !iv) { | ||
| throw new Error("네이버 연동 암호화 설정이 없습니다."); | ||
| } | ||
|
|
||
| const keyWordArray = parseKeyOrIv( | ||
| secret, | ||
| "VITE_NAVER_AES_SECRET", | ||
| AES_KEY_BYTE_LENGTHS, | ||
| ); | ||
| const ivWordArray = parseKeyOrIv(iv, "VITE_NAVER_AES_IV", [ | ||
| AES_IV_BYTE_LENGTH, | ||
| ]); | ||
|
|
||
| const encrypted = CryptoJS.AES.encrypt(plainText, keyWordArray, { | ||
| iv: ivWordArray, | ||
| mode: CryptoJS.mode.CBC, | ||
| padding: CryptoJS.pad.Pkcs7, | ||
| }); | ||
|
|
||
| return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== file =="
sed -n '1,220p' src/utils/integration/encryptAesCbcBase64.ts
echo
echo "== package / vite indicators =="
grep -nE '"vite"|import\.meta\.env|VITE_' package.json vite.config.* 2>/dev/null || true
echo
echo "== usages of encryptAesCbcBase64 =="
rg -n "encryptAesCbcBase64|VITE_NAVER_AES_SECRET|VITE_NAVER_AES_IV" src . --glob '!**/node_modules/**'Repository: WhereYouAd/WhereYouAd-Frontend
Length of output: 3677
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== naver integration api =="
sed -n '1,220p' src/api/integration/naver.ts
echo
echo "== vite config =="
sed -n '1,220p' vite.config.ts
echo
echo "== env references around naver =="
rg -n "NAVER_AES|encryptAesCbcBase64|apiKey:|secretKey:" src --glob '!**/node_modules/**'Repository: WhereYouAd/WhereYouAd-Frontend
Length of output: 4701
클라이언트 번들에서 AES 비밀키를 제거해 주세요
src/utils/integration/encryptAesCbcBase64.ts:32-56에서 VITE_NAVER_AES_SECRET를 브라우저 번들에 포함된 값으로 쓰고 있어 비밀키가 노출됩니다. VITE_NAVER_AES_IV도 고정값이라 CBC 패턴이 반복 노출됩니다. 이 방식은 서버 측으로 옮기거나, 최소한 요청마다 무작위 IV를 생성해 함께 전달하도록 바꿔야 합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/utils/integration/encryptAesCbcBase64.ts` around lines 32 - 56, The
encryptAesCbcBase64 helper is using build-time client env values for AES
secret/IV, which exposes sensitive material in the browser bundle. Update
encryptAesCbcBase64 to stop relying on VITE_NAVER_AES_SECRET and the fixed
VITE_NAVER_AES_IV in client-side code; move this encryption to a server-side
path or redesign it so a fresh random IV is generated per call and transmitted
alongside the ciphertext. Keep the changes localized around encryptAesCbcBase64
and parseKeyOrIv usage so the browser no longer contains reusable secret
material or a static CBC IV.
| import type { ITimelineSummaryPanelData } from "@/types/timeline/summary"; | ||
| import { TIMELINE_METRIC_OPTIONS } from "@/constants/timeline/formOptions"; | ||
|
|
||
| import { formatDot } from "./period"; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
@/ alias 대신 상대 경로 import가 사용되었습니다.
"./period"는 "@/utils/timeline/period"로 변경해야 합니다. As per coding guidelines, 모든 import에 @/ alias를 사용해야 합니다.
♻️ 제안하는 수정
-import { formatDot } from "./period";
+import { formatDot } from "`@/utils/timeline/period`";📝 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.
| import { formatDot } from "./period"; | |
| import { formatDot } from "`@/utils/timeline/period`"; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/utils/timeline/buildTimelineSummaryPanel.ts` at line 9, The import in
buildTimelineSummaryPanel should use the project’s "`@/`” alias instead of a
relative path. Update the existing formatDot import to reference
"`@/utils/timeline/period`" so the module follows the codebase import convention
and remains easy to locate alongside other timeline utilities.
Source: Coding guidelines
| function formatWeekLabel(start: Date, end: Date): string { | ||
| const startDay = start.getDate(); | ||
| const endDay = end.getDate(); | ||
| const startMonth = MONTH[start.getMonth()]; | ||
| const endMonth = MONTH[end.getMonth()]; | ||
|
|
||
| if (start.getMonth() === end.getMonth()) { | ||
| return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; | ||
| } | ||
| return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
formatWeekLabel if/else 양 분기가 동일한 결과 반환
같은 달인지 여부에 따라 다른 포맷을 의도했던 것으로 보이지만, 두 분기가 완전히 동일한 템플릿 리터럴을 반환하고 있어 if/else가 dead code입니다. 같은 달일 때는 "29 - 31 January"처럼 월명을 한 번만 표시하는 것이 자연스러울 수 있습니다.
♻️ 제안 수정 (같은 달 축약 포맷)
function formatWeekLabel(start: Date, end: Date): string {
const startDay = start.getDate();
const endDay = end.getDate();
const startMonth = MONTH[start.getMonth()];
const endMonth = MONTH[end.getMonth()];
if (start.getMonth() === end.getMonth()) {
- return `${startDay} ${startMonth} - ${endDay} ${endMonth}`;
+ return `${startDay} - ${endDay} ${startMonth}`;
}
return `${startDay} ${startMonth} - ${endDay} ${endMonth}`;
}📝 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.
| function formatWeekLabel(start: Date, end: Date): string { | |
| const startDay = start.getDate(); | |
| const endDay = end.getDate(); | |
| const startMonth = MONTH[start.getMonth()]; | |
| const endMonth = MONTH[end.getMonth()]; | |
| if (start.getMonth() === end.getMonth()) { | |
| return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; | |
| } | |
| return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; | |
| } | |
| function formatWeekLabel(start: Date, end: Date): string { | |
| const startDay = start.getDate(); | |
| const endDay = end.getDate(); | |
| const startMonth = MONTH[start.getMonth()]; | |
| const endMonth = MONTH[end.getMonth()]; | |
| if (start.getMonth() === end.getMonth()) { | |
| return `${startDay} - ${endDay} ${startMonth}`; | |
| } | |
| return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/utils/timeline/period.ts` around lines 67 - 77, formatWeekLabel has
redundant if/else branches that both return the same string, so the month-aware
branch is dead code. Update formatWeekLabel to use different formatting in the
same-month case versus cross-month case, likely by showing the month only once
when start.getMonth() === end.getMonth() and keeping the full start/end month
format otherwise. Use the existing formatWeekLabel, MONTH, and date extraction
logic to make the intended week label concise and distinct across the two cases.
🚨 관련 이슈
N/A
✨ 변경사항
✏️ 작업 내용
N/A
😅 미완성 작업
N/A
📢 논의 사항 및 참고 사항
N/A
Summary by CodeRabbit