Feat(client, design-system): 온보딩 직무 step UI 추가, checkbox 구현#257
Feat(client, design-system): 온보딩 직무 step UI 추가, checkbox 구현#257constantly-dev merged 11 commits intodevelopfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. Walkthrough온보딩에 직무 선택(Job) 단계를 추가하고(JobStep, MainCard·onboardingSteps 변경), 재사용 가능한 Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 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 |
|
✅ Storybook chromatic 배포 확인: |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (4)
apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx (2)
64-72: 의존성 없는useMemo→ 모듈 수준 상수로 추출 권장
jobs배열은 컴포넌트 외부 상수로 분리하면useMemo오버헤드 없이 동일한 효과를 얻을 수 있습니다. 아울러key필드를JobKey로 명시하면 하위 코드에서as JobKey단언(type assertion)도 제거할 수 있습니다.♻️ 제안된 리팩터
+const JOBS: { key: JobKey; label: string }[] = [ + { key: 'planner', label: '기획자' }, + { key: 'designer', label: '디자이너' }, + { key: 'frontend', label: '프론트엔드' }, + { key: 'backend', label: '백엔드' }, +]; const JobStep = ({ ... }: JobStepProps) => { - const jobs = useMemo( - () => [ - { key: 'planner', label: '기획자' }, - { key: 'designer', label: '디자이너' }, - { key: 'frontend', label: '프론트엔드' }, - { key: 'backend', label: '백엔드' }, - ], - [] - );그리고 map 내부에서:
- onClick={() => handleSelect(job.key as JobKey)} - className={jobCardStyle({ selected: isSelected })} + onClick={() => handleSelect(job.key)}- <JobIcon type={job.key as JobKey} /> + <JobIcon type={job.key} />🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx` around lines 64 - 72, The jobs array defined with useMemo in JobStep.tsx should be moved to a module-level constant to avoid unnecessary memoization overhead: replace the useMemo block with a top-level exported/const JOBS (or similar) and type it explicitly as Array<{ key: JobKey; label: string }>; update all references in the JobStep component to use this JOBS constant and remove any as JobKey assertions inside map/rendering since the keys are now strongly typed.
74-79:handleSelect조건식에서!onSelectJob분기 불필요controlled/uncontrolled 패턴의 관용적 기준은
selectedJob === undefined여부입니다.!onSelectJob조건을 추가하면 "콜백만 주고 값은 주지 않은" 경우에도 내부 상태를 업데이트해 예외적인 split-brain 상태를 만들 수 있습니다.♻️ 제안된 수정
const handleSelect = (job: JobKey) => { onSelectJob?.(job); - if (!onSelectJob || selectedJob === undefined) { + if (selectedJob === undefined) { setInternalJob(job); } }; const handleAgreeChange = (checked: boolean) => { onAgreeChange?.(checked); - if (!onAgreeChange || agreeChecked === undefined) { + if (agreeChecked === undefined) { setInternalAgree(checked); } };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx` around lines 74 - 79, The handleSelect function currently updates internal state when either onSelectJob is missing or selectedJob is undefined, which can produce split-brain when a callback is provided without a value; change the conditional to only check selectedJob === undefined so internal state (setInternalJob) is updated only in uncontrolled mode; keep the existing optional call to onSelectJob?.(job) but remove the !onSelectJob check so handleSelect uses selectedJob as the single source for controlled vs uncontrolled behavior.packages/design-system/src/components/index.ts (1)
4-5:Checkboxexport가 알파벳 순서에서Chip뒤에 위치"Checkbox" < "Chip" (알파벳 순) 이므로
Chip앞에 오도록 순서를 조정하는 것이 barrel 파일의 일관성을 유지합니다.♻️ 제안된 수정
export { default as Card } from './card/Card'; +export { default as Checkbox } from './checkbox/Checkbox'; export { default as Chip } from './chip/Chip'; -export { default as Checkbox } from './checkbox/Checkbox';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/design-system/src/components/index.ts` around lines 4 - 5, The barrel export order is not alphabetized: move the export for Checkbox so it appears before Chip in the components index (update the exports for "Checkbox" and "Chip" in components/index.ts), ensuring the exported symbols are listed in alphabetical order (e.g., export { default as Checkbox } ... appears above export { default as Chip } ...).packages/design-system/src/components/checkbox/Checkbox.stories.tsx (1)
43-45:Medium스토리가Selected와 실질적으로 중복 —Small스토리 누락meta의 default args가 이미
size: 'medium'이므로Medium스토리(size: 'medium', isSelected: true)는Selected스토리(isSelected: true)와 시각적으로 동일합니다. 대신 아직 문서화되지 않은Small사이즈 스토리를 추가하는 것이 API 커버리지 측면에서 더 유용합니다.♻️ 제안된 수정
-export const Medium: Story = { - args: { size: 'medium', isSelected: true }, -}; +export const Small: Story = { + args: { size: 'small', isSelected: false }, +}; + +export const SmallSelected: Story = { + args: { size: 'small', isSelected: true }, +};🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/design-system/src/components/checkbox/Checkbox.stories.tsx` around lines 43 - 45, The Medium story duplicates Selected because meta.default args already set size: 'medium'; replace or remove the redundant export Medium and add a new Small story export (e.g., export const Small: Story) that sets args: { size: 'small', isSelected: true } so the small-size checkbox is covered; keep Selected as-is and ensure meta/default args remain unchanged so stories use the correct defaults.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx`:
- Line 68: The state jobShareAgree is initialized to true which pre-checks
consent and effectively forces agreement via the isDisabled={step === Step.JOB
&& !jobShareAgree} check; change the default to false so users must opt-in
(update useState usage for jobShareAgree and setJobShareAgree), and verify
downstream logic that references jobShareAgree (the isDisabled condition and any
submit handlers in the MainCard component) still behaves correctly or instead
allow advancing without consent while gating only the consent-dependent
features; ensure no other code assumes a default true value.
- Around line 140-146: MainCard currently doesn't pass selected job state into
JobStep nor include it in the signup payload; add a state in MainCard (e.g.,
const [selectedJob, setSelectedJob] = useState<JobKey>('planner')) and pass
selectedJob and onSelectJob={setSelectedJob} into the <JobStep ... /> call
(alongside existing jobShareAgree and setJobShareAgree), then include
selectedJob in the postSignData payload (e.g., postSignData({ email,
remindDefault, fcmToken, job: selectedJob }, ...)) so the chosen job is
preserved across unmounts and sent to the backend.
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx`:
- Around line 57-58: The checkbox state internalAgree is defaulting to true
(useState(true)) which creates a pre-checked opt-out pattern inappropriate for
personal data consent; change the initializer in the JobStep component to
useState(false) (update internalAgree and any use of setInternalAgree), and then
update the flow in MainCard.tsx to allow progressing without consent or to
explicitly disable the feature when internalAgree is false so that consent is
opt-in and not required by implicit pre-check.
In `@packages/design-system/src/components/checkbox/Checkbox.tsx`:
- Around line 1-3: The file uses the React namespace types
(React.InputHTMLAttributes and React.ChangeEvent) but doesn't import React into
the type scope; add a type-only import for the React namespace (e.g., import
type React from 'react') at the top of the file so
React.InputHTMLAttributes<HTMLInputElement> and
React.ChangeEvent<HTMLInputElement> in the CheckboxProps/Checkbox component
resolve without TS errors.
---
Nitpick comments:
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx`:
- Around line 64-72: The jobs array defined with useMemo in JobStep.tsx should
be moved to a module-level constant to avoid unnecessary memoization overhead:
replace the useMemo block with a top-level exported/const JOBS (or similar) and
type it explicitly as Array<{ key: JobKey; label: string }>; update all
references in the JobStep component to use this JOBS constant and remove any as
JobKey assertions inside map/rendering since the keys are now strongly typed.
- Around line 74-79: The handleSelect function currently updates internal state
when either onSelectJob is missing or selectedJob is undefined, which can
produce split-brain when a callback is provided without a value; change the
conditional to only check selectedJob === undefined so internal state
(setInternalJob) is updated only in uncontrolled mode; keep the existing
optional call to onSelectJob?.(job) but remove the !onSelectJob check so
handleSelect uses selectedJob as the single source for controlled vs
uncontrolled behavior.
In `@packages/design-system/src/components/checkbox/Checkbox.stories.tsx`:
- Around line 43-45: The Medium story duplicates Selected because meta.default
args already set size: 'medium'; replace or remove the redundant export Medium
and add a new Small story export (e.g., export const Small: Story) that sets
args: { size: 'small', isSelected: true } so the small-size checkbox is covered;
keep Selected as-is and ensure meta/default args remain unchanged so stories use
the correct defaults.
In `@packages/design-system/src/components/index.ts`:
- Around line 4-5: The barrel export order is not alphabetized: move the export
for Checkbox so it appears before Chip in the components index (update the
exports for "Checkbox" and "Chip" in components/index.ts), ensuring the exported
symbols are listed in alphabetical order (e.g., export { default as Checkbox }
... appears above export { default as Chip } ...).
| const [userEmail, setUserEmail] = useState(''); | ||
| const [remindTime, setRemindTime] = useState('09:00'); | ||
| const [fcmToken, setFcmToken] = useState<string | null>(null); | ||
| const [jobShareAgree, setJobShareAgree] = useState(true); |
There was a problem hiding this comment.
jobShareAgree 기본값 true + 미동의 시 진행 불가 = 강제 동의 패턴
jobShareAgree가 true로 초기화되어 동의 체크박스가 사전 선택된 상태로 표시되고, 사용자가 이를 해제하면 isDisabled={step === Step.JOB && !jobShareAgree}에 의해 다음 버튼이 비활성화됩니다. 이는 사실상 "동의하지 않으면 사용 불가"인 강제 동의 구조로, GDPR 제7조 4항의 동의 번들링 금지 원칙(개인정보 공유 동의를 서비스 이용의 조건으로 강제)에 위반될 수 있습니다.
권장 수정:
- 기본값을
false로 변경하여 사용자가 명시적으로 동의를 선택(opt-in)하도록 설계 - 또는, 동의 없이도 다음 단계로 진행 가능하게 하고 단순히 기능을 비활성화하는 방식 고려
🛡️ 제안된 수정
- const [jobShareAgree, setJobShareAgree] = useState(true);
+ const [jobShareAgree, setJobShareAgree] = useState(false);- isDisabled={step === Step.JOB && !jobShareAgree}
+ isDisabled={false} // 동의는 선택 사항으로 처리Also applies to: 263-263
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx` at line 68,
The state jobShareAgree is initialized to true which pre-checks consent and
effectively forces agreement via the isDisabled={step === Step.JOB &&
!jobShareAgree} check; change the default to false so users must opt-in (update
useState usage for jobShareAgree and setJobShareAgree), and verify downstream
logic that references jobShareAgree (the isDisabled condition and any submit
handlers in the MainCard component) still behaves correctly or instead allow
advancing without consent while gating only the consent-dependent features;
ensure no other code assumes a default true value.
| case Step.JOB: | ||
| return ( | ||
| <JobStep | ||
| agreeChecked={jobShareAgree} | ||
| onAgreeChange={setJobShareAgree} | ||
| /> | ||
| ); |
There was a problem hiding this comment.
선택된 직무(job)가 API 호출에 포함되지 않아 기능 목적이 불완전
JobStep에 selectedJob/onSelectJob이 전달되지 않아 직무 선택 결과가 JobStep 내부 상태에만 저장되고, 다음 단계로 이동(컴포넌트 언마운트) 시 소실됩니다. 회원가입 시 호출되는 postSignData도 { email, remindDefault, fcmToken }만 전달하므로 선택된 직무 정보는 백엔드로 전송되지 않습니다.
직무 데이터가 온보딩 완료 시 실제로 필요하다면 MainCard 수준에서 상태로 관리하고 API 페이로드에 포함해야 합니다.
// MainCard 내 상태 추가
const [selectedJob, setSelectedJob] = useState<JobKey>('planner');
// JobStep에 전달
<JobStep
selectedJob={selectedJob}
onSelectJob={setSelectedJob}
agreeChecked={jobShareAgree}
onAgreeChange={setJobShareAgree}
/>
// postSignData 페이로드에 포함
postSignData({ email: userEmail, remindDefault: remindTime, fcmToken, job: selectedJob }, ...);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx` around lines
140 - 146, MainCard currently doesn't pass selected job state into JobStep nor
include it in the signup payload; add a state in MainCard (e.g., const
[selectedJob, setSelectedJob] = useState<JobKey>('planner')) and pass
selectedJob and onSelectJob={setSelectedJob} into the <JobStep ... /> call
(alongside existing jobShareAgree and setJobShareAgree), then include
selectedJob in the postSignData payload (e.g., postSignData({ email,
remindDefault, fcmToken, job: selectedJob }, ...)) so the chosen job is
preserved across unmounts and sent to the backend.
| const [internalJob, setInternalJob] = useState<JobKey>(defaultJob); | ||
| const [internalAgree, setInternalAgree] = useState(true); |
There was a problem hiding this comment.
internalAgree 기본값 true는 사전 동의(pre-checked) 패턴 — 개인정보 처리 동의로는 부적절
동의 체크박스가 기본적으로 선택된 상태(true)로 초기화됩니다. 해당 동의의 내용("내 Google 이름과 함께 다른 사용자에게 추천")은 개인정보(식별자) 공유에 해당하므로, GDPR 제7조 및 국내 개인정보보호법에 따라 명시적이고 능동적인 동의(opt-in)를 요구합니다. 사전 체크(opt-out) 방식은 유효한 동의로 인정되지 않으며, MainCard.tsx에서 미동의 시 다음 단계 진행이 불가능한 구조와 결합되면 사실상 강제 동의가 됩니다.
기본값을 false로 변경하고, 동의 없이도 진행 가능하도록(또는 해당 기능을 비활성화) 설계를 재검토하는 것을 권장합니다.
🛡️ 제안된 수정
- const [internalAgree, setInternalAgree] = useState(true);
+ const [internalAgree, setInternalAgree] = useState(false);📝 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 [internalJob, setInternalJob] = useState<JobKey>(defaultJob); | |
| const [internalAgree, setInternalAgree] = useState(true); | |
| const [internalJob, setInternalJob] = useState<JobKey>(defaultJob); | |
| const [internalAgree, setInternalAgree] = useState(false); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx` around
lines 57 - 58, The checkbox state internalAgree is defaulting to true
(useState(true)) which creates a pre-checked opt-out pattern inappropriate for
personal data consent; change the initializer in the JobStep component to
useState(false) (update internalAgree and any use of setInternalAgree), and then
update the flow in MainCard.tsx to allow progressing without consent or to
explicitly disable the feature when internalAgree is false so that consent is
opt-in and not required by implicit pre-check.
| import { cva } from 'class-variance-authority'; | ||
| import { useId, useState } from 'react'; | ||
| import { cn } from '../../lib/utils'; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Checkbox.tsx 파일 전체 내용 확인 (라인 번호와 함께)
cat -n packages/design-system/src/components/checkbox/Checkbox.tsxRepository: Pinback-Team/pinback-client
Length of output: 4053
🏁 Script executed:
# tsconfig 설정 확인
fd 'tsconfig' --type f -x cat -n {}Repository: Pinback-Team/pinback-client
Length of output: 2923
🏁 Script executed:
# 프로젝트 React 버전 확인
cd packages/design-system && cat -n package.json | grep -A 5 -B 5 '"react"'Repository: Pinback-Team/pinback-client
Length of output: 462
🏁 Script executed:
# 다른 컴포넌트들의 import 패턴 확인
rg "^import.*React" packages/design-system/src/components/ --type tsx -A 3 | head -40Repository: Pinback-Team/pinback-client
Length of output: 98
🏁 Script executed:
# 확장된 tsconfig의 base configuration 확인
find . -path "*/typescript-config/react.json" -o -path "*/typescript-config/base.json" | head -5Repository: Pinback-Team/pinback-client
Length of output: 149
🏁 Script executed:
# 다른 컴포넌트에서 React 타입 사용 패턴 확인
rg "React\.(InputHTMLAttributes|ChangeEvent|CSSProperties)" packages/design-system/src/components/ --type tsx | head -20Repository: Pinback-Team/pinback-client
Length of output: 98
🏁 Script executed:
# 프로젝트에서 React 타입을 어떻게 임포트하는지 패턴 확인
rg "import.*(?:InputHTMLAttributes|ChangeEvent)" packages/design-system/src/components/ --type tsxRepository: Pinback-Team/pinback-client
Length of output: 98
🏁 Script executed:
# typescript-config 파일 확인
cat packages/typescript-config/react.jsonRepository: Pinback-Team/pinback-client
Length of output: 617
🏁 Script executed:
# React 네임스페이스 타입 사용 패턴 확인
rg "React\.(InputHTMLAttributes|ChangeEvent|CSSProperties)" packages/design-system/src/components/Repository: Pinback-Team/pinback-client
Length of output: 425
🏁 Script executed:
# 다른 컴포넌트에서 type import 패턴 확인
rg "import type.*from 'react'" packages/design-system/src/components/ | head -20Repository: Pinback-Team/pinback-client
Length of output: 53
🏁 Script executed:
# 다른 컴포넌트에서 React 직접 임포트 패턴 확인
rg "^import.*React" packages/design-system/src/components/ | head -20Repository: Pinback-Team/pinback-client
Length of output: 662
React 네임스페이스 미임포트 — TypeScript 타입 오류
React.InputHTMLAttributes<HTMLInputElement>(라인 7)과 React.ChangeEvent<HTMLInputElement>(라인 73)에서 React 네임스페이스를 사용하지만, 파일의 import 문(라인 1-3)에서 React 네임스페이스를 임포트하지 않았습니다.
새 JSX 변환(jsx: react-jsx)을 사용하면 JSX는 React를 필요로 하지 않지만, 타입 참조는 여전히 React를 scope에 두어야 합니다. 프로젝트의 기본 tsconfig에서 strict: true로 설정되어 있으므로, 이 코드는 TypeScript 컴파일 오류를 발생시킵니다.
명시적으로 named type import를 사용하는 것을 권장합니다:
제안된 수정
+import type { InputHTMLAttributes, ChangeEvent } from 'react';
import { cva } from 'class-variance-authority';
import { useId, useState } from 'react';
import { cn } from '../../lib/utils';
interface CheckboxProps
extends Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
+ InputHTMLAttributes<HTMLInputElement>,
'type' | 'size' | 'checked' | 'defaultChecked' | 'onChange'
> {- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/design-system/src/components/checkbox/Checkbox.tsx` around lines 1 -
3, The file uses the React namespace types (React.InputHTMLAttributes and
React.ChangeEvent) but doesn't import React into the type scope; add a type-only
import for the React namespace (e.g., import type React from 'react') at the top
of the file so React.InputHTMLAttributes<HTMLInputElement> and
React.ChangeEvent<HTMLInputElement> in the CheckboxProps/Checkbox component
resolve without TS errors.
| <div | ||
| className={CardStyle({ | ||
| overflow: step === Step.ALARM && alarmSelected === 3, | ||
| size: step === Step.JOB ? 'wide' : 'default', |
There was a problem hiding this comment.
직무 step이 다른 알람 step과 다르게 더 넓은 width값을 가지고 있어서 이를 분기처리하긴했어요.
처음에는 container에 width값을 안주고 내부 요소에 width를 설정해서 조절하도록 할까 했는데, 맥북 알림/리마인드 시간 선택 step등이 내부는 다른데 container는 63.2rem으로 동일해서 일단 저렇게 wide값을 가질때를 분기했어요.
사실 저렇게 하면 step이 바뀔 때 container의 width값이 변경되니 CLS(레이아웃 변경 횟수) 평가 지표에 좋지 않은 영향이 갈 것이라고 생각해요. 그래서 일단 디자이너에게 통일할 수 있는 방법을 물어본 상태이고, 만약 안된다면 layout 변경을 최소화하고 최적화 할 수 있는지 체크해볼게요!
| const isUncontrolled = isSelected === undefined; | ||
| const selected = isUncontrolled ? internalSelected : isSelected; |
There was a problem hiding this comment.
checkbox 컴포넌트를 제어/비제어 컴포넌트 모두 사용할 수 있게 선택권을 주도록 설계했어요.
만약 isSelected를 부모에서 관리하는 경우는 제어 컴포넌트고, 아니라면 비제어 컴포넌트로 내부 internalSelected 상태 값을 가지고/변경 되도록 했어요.
물론 대부분의 checkbox의 경우 사용하는 부모에서 그 체크 값을 form 등에서 사용하기 때문에 제어 컴포넌트로 많이 사용하겠지만, 단순 UI 용으로 checkbox를 사용하는 등의 경우는 비제어로도 사용이 가능해요.
There was a problem hiding this comment.
우와.. 제 생각 이 짧았네요 이런 방식도 있군요..배워갑니당
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/design-system/src/components/checkbox/Checkbox.tsx (1)
58-67:ref미노출 — 폼 라이브러리 통합에 제약디자인 시스템 컴포넌트에서 네이티브
<input>에 대한 ref 접근은react-hook-form등 폼 라이브러리 연동에 필수적입니다. React 19에서는forwardRef없이ref를 일반 prop으로 받을 수 있으므로Ref<HTMLInputElement>를CheckboxProps에 추가하는 것을 권장합니다.♻️ 제안된 개선
-import { ChangeEvent, InputHTMLAttributes, useId, useState } from 'react'; +import { ChangeEvent, InputHTMLAttributes, Ref, useId, useState } from 'react'; interface CheckboxProps extends Omit< InputHTMLAttributes<HTMLInputElement>, 'type' | 'size' | 'checked' | 'defaultChecked' | 'onChange' > { isSelected?: boolean; defaultSelected?: boolean; onSelectedChange?: (checked: boolean) => void; size?: 'small' | 'medium'; + ref?: Ref<HTMLInputElement>; } const Checkbox = ({ isSelected, defaultSelected = false, onSelectedChange, size = 'medium', className, id, disabled, + ref, ...props }: CheckboxProps) => {<input id={inputId} type="checkbox" className="peer sr-only" checked={selected} disabled={disabled} onChange={handleChange} + ref={ref} {...props} />Based on learnings: "React 19부터 함수 컴포넌트에서 ref를 일반 prop으로 받을 수 있어 forwardRef가 더 이상 필요하지 않다. InputProps interface에서
ref: Ref<HTMLInputElement>로 정의하고 함수 컴포넌트에서({ ref, ...props }) =>형태로 받을 수 있다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/design-system/src/components/checkbox/Checkbox.tsx` around lines 58 - 67, The Checkbox component currently doesn't expose a ref which prevents form libs from accessing the native input; update the CheckboxProps type to include ref: React.Ref<HTMLInputElement> (import Ref/React as needed), accept ref in the Checkbox function signature (e.g., include ref in the destructured props for Checkbox), and forward that ref down to the native <input> element rendered by Checkbox (ensure the input's ref prop uses the incoming ref). Keep existing isSelected/defaultSelected/onSelectedChange behavior and types intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/design-system/src/components/checkbox/Checkbox.tsx`:
- Around line 85-131: The visible checkbox container currently uses a <span>
with aria-hidden="true" and isn't associated with the hidden <input> so
mouse/touch clicks don't toggle the checkbox; change the span to a <label
htmlFor={inputId}> (using the existing inputId from useId) so the visible
element is associated with the input, remove aria-hidden (or ensure it is not
set to true) on that element, preserve the className logic that uses
checkboxBoxVariants and checkIconVariants, and keep the <input id={inputId} ...
/> and onChange={handleChange} intact so clicks on the label toggle the input's
selected state.
---
Duplicate comments:
In `@packages/design-system/src/components/checkbox/Checkbox.tsx`:
- Line 3: Import the missing ChangeEvent named export from React and use it
consistently to fix the TypeScript compile error: add ChangeEvent to the
existing named imports (alongside InputHTMLAttributes, useId, useState) and
replace occurrences of React.ChangeEvent<HTMLInputElement> with
ChangeEvent<HTMLInputElement> in Checkbox.tsx (e.g., where event types are
declared for onChange handlers), ensuring the type reference matches the new
import.
---
Nitpick comments:
In `@packages/design-system/src/components/checkbox/Checkbox.tsx`:
- Around line 58-67: The Checkbox component currently doesn't expose a ref which
prevents form libs from accessing the native input; update the CheckboxProps
type to include ref: React.Ref<HTMLInputElement> (import Ref/React as needed),
accept ref in the Checkbox function signature (e.g., include ref in the
destructured props for Checkbox), and forward that ref down to the native
<input> element rendered by Checkbox (ensure the input's ref prop uses the
incoming ref). Keep existing isSelected/defaultSelected/onSelectedChange
behavior and types intact.
| return ( | ||
| <div | ||
| className={cn( | ||
| 'inline-flex items-center', | ||
| disabled && 'cursor-not-allowed', | ||
| className | ||
| )} | ||
| > | ||
| <input | ||
| id={inputId} | ||
| type="checkbox" | ||
| className="peer sr-only" | ||
| checked={selected} | ||
| disabled={disabled} | ||
| onChange={handleChange} | ||
| {...props} | ||
| /> | ||
|
|
||
| <span | ||
| aria-hidden="true" | ||
| data-state={selected ? 'checked' : 'unchecked'} | ||
| className={cn( | ||
| checkboxBoxVariants({ | ||
| size, | ||
| selected, | ||
| disabled: Boolean(disabled), | ||
| }) | ||
| )} | ||
| > | ||
| <svg | ||
| viewBox="0 0 12 9" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| className={cn(checkIconVariants({ size, selected }))} | ||
| > | ||
| <path | ||
| d="M1 4.5L4.5 8L11 1" | ||
| stroke="currentColor" | ||
| strokeWidth="1.5" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| /> | ||
| </svg> | ||
| </span> | ||
| </div> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
클릭이 동작하지 않는 구조 — <label> 연결 누락
<input>은 sr-only로 시각적으로 숨겨져 있고, 보이는 <span>에는 aria-hidden="true"만 있고 onClick도 없습니다. 컴포넌트 내부에 <label htmlFor={inputId}>가 없어 마우스/터치 클릭으로 체크박스를 토글할 수 없습니다. 키보드(Tab+Space)로만 동작하는 상태입니다.
useId로 생성한 inputId가 컴포넌트 안에서 활용되지 않고 있습니다. <span>을 <label htmlFor={inputId}>로 교체하는 것이 가장 간단한 수정입니다.
🐛 제안된 수정
- <span
+ <label
+ htmlFor={inputId}
aria-hidden="true"
data-state={selected ? 'checked' : 'unchecked'}
className={cn(
checkboxBoxVariants({
size,
selected,
disabled: Boolean(disabled),
})
)}
>
<svg
viewBox="0 0 12 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(checkIconVariants({ size, selected }))}
>
<path
d="M1 4.5L4.5 8L11 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
- </span>
+ </label>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/design-system/src/components/checkbox/Checkbox.tsx` around lines 85
- 131, The visible checkbox container currently uses a <span> with
aria-hidden="true" and isn't associated with the hidden <input> so mouse/touch
clicks don't toggle the checkbox; change the span to a <label htmlFor={inputId}>
(using the existing inputId from useId) so the visible element is associated
with the input, remove aria-hidden (or ensure it is not set to true) on that
element, preserve the className logic that uses checkboxBoxVariants and
checkIconVariants, and keep the <input id={inputId} ... /> and
onChange={handleChange} intact so clicks on the label toggle the input's
selected state.
jjangminii
left a comment
There was a problem hiding this comment.
확실히 코드 설계에 있어 고민한 흔적이 느껴지네요,, 저도 많이 배워갑니당 질문 몇개만 답변해주세요-!
| @@ -0,0 +1,133 @@ | |||
| import { cva } from 'class-variance-authority'; | |||
| import { cn } from '../../lib/utils'; | |||
There was a problem hiding this comment.
절대경로를 하는 목적이 상대 경로가 길어지고 파악이 어려울 때 사용한다고 생각하는데, pds 내에서 적게 이동하는 cn 함수의 경우에는 크게 파악하는데 문제가 없다고 생각이 들었어요!
| const defaultJob: JobKey = 'planner'; | ||
| const [internalJob, setInternalJob] = useState<JobKey>(defaultJob); | ||
| const [internalAgree, setInternalAgree] = useState(true); | ||
| const agreeId = useId(); |
There was a problem hiding this comment.
useId는 어떻게 동작하나요? 이게 왜 필요한지 궁금합니다
There was a problem hiding this comment.
useId는 고유 ID를 생성하기 위한 React Hook이에요.
어트리뷰트에 unique한 값을 넣을 때 보통 사용하는데, 해당 jobStep에서도 label태그의 htmlFor와 input의 id에 같은 값을 연결하기 위해 직접 값을 입력하는 것이 아닌 useId를 사용했어요!
다만 지금 생각해보니 label 태그 안에 input을 넣을 경우는 굳이 htmlFor로 연결을 안해도 되는 것 같아서 해당 useId는 제거할게요!
| const activeJob = selectedJob ?? internalJob; | ||
| const activeAgree = agreeChecked ?? internalAgree; | ||
|
|
||
| const jobs = useMemo( |
There was a problem hiding this comment.
불필요한 useMemo 같네요! 제거 완완입니다~
| const JobIcon = ({ type }: { type: JobKey }) => { | ||
| const iconMap: Record<JobKey, string> = { | ||
| planner: jobPlan, | ||
| designer: jobDesign, | ||
| frontend: jobFrontend, | ||
| backend: jobBackend, | ||
| }; |
There was a problem hiding this comment.
해당 JobIcon은 컴포넌트입니다! JobStep과 많이 연관되어있다고 판단해서 안에 같이 두는 것도 좋을 것 같은데 어떻게 생각하시나요?
| InputHTMLAttributes<HTMLInputElement>, | ||
| 'type' | 'size' | 'checked' | 'defaultChecked' | 'onChange' | ||
| > { | ||
| isSelected?: boolean; |
There was a problem hiding this comment.
isSelected가 옵셔널인 이유가 있을까요? 그리고 isSelected와 defaultSelected를 구분한 이유도 궁금합니다.
| const isUncontrolled = isSelected === undefined; | ||
| const selected = isUncontrolled ? internalSelected : isSelected; |
There was a problem hiding this comment.
우와.. 제 생각 이 짧았네요 이런 방식도 있군요..배워갑니당
| const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const nextChecked = e.target.checked; | ||
|
|
||
| if (isUncontrolled) setInternalSelected(nextChecked); | ||
|
|
||
| onSelectedChange?.(nextChecked); | ||
| }; |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx (2)
89-89: 장식용 이미지dotori에aria-hidden="true"누락
JobIcon에는aria-hidden="true"가 적용되어 있는데, 같은 파일의 dotori 이미지는 누락되어 있습니다. 순수 장식용이라면 스크린 리더가"dotori"를 불필요하게 읽게 됩니다.♻️ 제안된 수정
- <img src={dotori} className="mb-[1.2rem]" alt="dotori" /> + <img src={dotori} className="mb-[1.2rem]" alt="" aria-hidden="true" />🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx` at line 89, The decorative dotori image in JobStep.tsx is missing accessibility markup; update the <img src={dotori} ... /> element (the dotori image in the JobStep component) to be treated as purely decorative by screen readers—add aria-hidden="true" (and/or set an empty alt="" if you prefer) to match how JobIcon is handled so assistive tech ignores it.
63-71:useMemo의 빈 의존성 배열([]) — 모듈 상수로 대체 가능
jobs배열은 컴포넌트의 어떤 상태나 props에도 의존하지 않는 순수 정적 데이터입니다.useMemo(fn, [])는 "렌더 전까지 계산을 미루는" 효과만 있을 뿐, 모듈 스코프const로 정의하는 것과 의미상 동일합니다.♻️ 제안된 리팩터
+const JOBS: { key: JobKey; label: string }[] = [ + { key: 'planner', label: '기획자' }, + { key: 'designer', label: '디자이너' }, + { key: 'frontend', label: '프론트엔드' }, + { key: 'backend', label: '백엔드' }, +]; + const JobStep = ({ ... }: JobStepProps) => { ... - const jobs = useMemo( - () => [ - { key: 'planner', label: '기획자' }, - { key: 'designer', label: '디자이너' }, - { key: 'frontend', label: '프론트엔드' }, - { key: 'backend', label: '백엔드' }, - ], - [] - ); + // 모듈 상수 JOBS 사용🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx` around lines 63 - 71, The jobs array is static and wrapped unnecessarily in useMemo inside the JobStep component; replace the useMemo(...) call by defining a module‑level constant (e.g., const jobs = [{ key: 'planner', ... }, ...]) outside the JobStep component, remove the useMemo usage (and its import if now unused), and update any references to the jobs identifier inside JobStep to use the module constant.apps/client/src/pages/onBoarding/GoogleCallback.tsx (1)
47-49:redirectUri를 컴포넌트 외부 모듈 스코프로 이동하는 것을 권장합니다
import.meta.env는 빌드 타임 상수이므로 렌더마다 재계산될 이유가 없습니다. 컴포넌트 외부로 이동하면 의도가 명확해지고 불필요한 재평가도 제거됩니다.♻️ 제안된 리팩터
+const redirectUri = import.meta.env.PROD + ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD + : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; + const GoogleCallback = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); ... - const redirectUri = import.meta.env.PROD - ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD - : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; - const loginWithCode = async (code: string) => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/client/src/pages/onBoarding/GoogleCallback.tsx` around lines 47 - 49, The redirectUri constant inside GoogleCallback.tsx should be moved out of the React component into module scope to avoid re-evaluating import.meta.env on every render; locate the current const redirectUri = import.meta.env.PROD ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV and hoist it to the top-level of the file (above the component function) so the value is computed once at build time and the component uses that module-scoped redirectUri.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx`:
- Around line 97-126: The radio-group pattern is missing ARIA arrow-key
navigation; update the JobStep card buttons so arrow keys move selection and Tab
focuses the selected item: add an onKeyDown handler (e.g., handleKeyDown(event,
job.key)) and implement handleKeyDown to call handleSelect with the
next/previous JobKey on ArrowLeft/ArrowUp/ArrowRight/ArrowDown, and set tabIndex
on each button to (isSelected ? 0 : -1) so only the selected item is tabbable;
keep role="radiogroup" and role="radio" (or remove roles if you prefer simple
buttons) and reuse existing identifiers like jobs.map, activeJob, handleSelect,
and jobCardStyle to locate and wire up these changes.
In `@apps/client/src/pages/onBoarding/GoogleCallback.tsx`:
- Around line 47-56: The redirectUri computed at top (variable redirectUri) can
be undefined and is passed from loginWithCode into
apiRequest.post('/api/v3/auth/google', { code, uri }), causing the v3 endpoint
to reject the request; add a safe fallback (e.g., a known default redirect URL)
when computing redirectUri and validate it at the start of loginWithCode before
calling apiRequest.post — if redirectUri is missing, either populate it with the
fallback or throw/handle an explicit error and avoid sending {uri: undefined} so
the API always receives a defined uri field; update the redirectUri
initialization and add a pre-send check in loginWithCode to enforce this.
---
Duplicate comments:
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx`:
- Line 58: The state internalAgree in JobStep.tsx is initialized to true
(opt-out); change the default to false by updating useState(true) →
useState(false) and ensure the toggle handler (setInternalAgree) and any
submission/next-step guard that reads internalAgree (referencing JobStep
component and the step advancement logic in MainCard.tsx) treat false as the
default and require an explicit user action to set it true before allowing
progression; review and adjust the MainCard.tsx next-step validation to check
internalAgree === true before advancing and update any tests or UI copy if
needed.
---
Nitpick comments:
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx`:
- Line 89: The decorative dotori image in JobStep.tsx is missing accessibility
markup; update the <img src={dotori} ... /> element (the dotori image in the
JobStep component) to be treated as purely decorative by screen readers—add
aria-hidden="true" (and/or set an empty alt="" if you prefer) to match how
JobIcon is handled so assistive tech ignores it.
- Around line 63-71: The jobs array is static and wrapped unnecessarily in
useMemo inside the JobStep component; replace the useMemo(...) call by defining
a module‑level constant (e.g., const jobs = [{ key: 'planner', ... }, ...])
outside the JobStep component, remove the useMemo usage (and its import if now
unused), and update any references to the jobs identifier inside JobStep to use
the module constant.
In `@apps/client/src/pages/onBoarding/GoogleCallback.tsx`:
- Around line 47-49: The redirectUri constant inside GoogleCallback.tsx should
be moved out of the React component into module scope to avoid re-evaluating
import.meta.env on every render; locate the current const redirectUri =
import.meta.env.PROD ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD :
import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV and hoist it to the top-level of
the file (above the component function) so the value is computed once at build
time and the component uses that module-scoped redirectUri.
| <div | ||
| role="radiogroup" | ||
| aria-label="직무 선택" | ||
| className="grid w-full grid-cols-2 justify-items-center gap-[1.4rem] sm:grid-cols-4" | ||
| > | ||
| {jobs.map((job) => { | ||
| const isSelected = activeJob === job.key; | ||
| return ( | ||
| <button | ||
| key={job.key} | ||
| type="button" | ||
| role="radio" | ||
| aria-checked={isSelected} | ||
| onClick={() => handleSelect(job.key as JobKey)} | ||
| className={jobCardStyle({ selected: isSelected })} | ||
| > | ||
| <div className="flex flex-col items-center gap-[1.6rem]"> | ||
| <JobIcon type={job.key as JobKey} /> | ||
| <span | ||
| className={`sub3-sb ${ | ||
| isSelected ? 'text-main500' : 'text-font-black-1' | ||
| }`} | ||
| > | ||
| {job.label} | ||
| </span> | ||
| </div> | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> |
There was a problem hiding this comment.
role="radiogroup" + role="radio" 패턴에서 키보드 화살표 키 내비게이션이 구현되지 않았습니다
WAI-ARIA Authoring Practices의 Radio Group 패턴에 따르면, role="radio" 요소 간 이동은 화살표 키(←→↑↓) 로 해야 합니다. Tab 키는 그룹 전체를 단일 포커스 단위로 취급하고, 화살표 키로 내부 선택을 변경합니다. 현재 구현은 <button> 요소를 그대로 사용하기 때문에 Tab으로 각 카드를 개별 탐색하게 되어 ARIA 스펙과 어긋납니다.
화살표 키 핸들러를 추가하거나, ARIA radiogroup 패턴 대신 단순 <button> 그룹으로 역할을 바꾸는(role 제거) 방향을 검토해주세요.
♿ 화살표 키 내비게이션 추가 예시
+ const jobKeys = jobs.map((j) => j.key as JobKey);
+
+ const handleKeyDown = (e: React.KeyboardEvent, currentKey: JobKey) => {
+ const idx = jobKeys.indexOf(currentKey);
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ handleSelect(jobKeys[(idx + 1) % jobKeys.length]);
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ handleSelect(jobKeys[(idx - 1 + jobKeys.length) % jobKeys.length]);
+ }
+ };각 <button> 에 onKeyDown={(e) => handleKeyDown(e, job.key as JobKey)} 를 추가하고, Tab 포커스는 선택된 항목(tabIndex={isSelected ? 0 : -1})에만 두도록 조정하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx` around
lines 97 - 126, The radio-group pattern is missing ARIA arrow-key navigation;
update the JobStep card buttons so arrow keys move selection and Tab focuses the
selected item: add an onKeyDown handler (e.g., handleKeyDown(event, job.key))
and implement handleKeyDown to call handleSelect with the next/previous JobKey
on ArrowLeft/ArrowUp/ArrowRight/ArrowDown, and set tabIndex on each button to
(isSelected ? 0 : -1) so only the selected item is tabbable; keep
role="radiogroup" and role="radio" (or remove roles if you prefer simple
buttons) and reuse existing identifiers like jobs.map, activeJob, handleSelect,
and jobCardStyle to locate and wire up these changes.
| const redirectUri = import.meta.env.PROD | ||
| ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD | ||
| : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; | ||
|
|
||
| const loginWithCode = async (code: string) => { | ||
| try { | ||
| const res = await apiRequest.post('/api/v2/auth/google', { code }); | ||
| const res = await apiRequest.post('/api/v3/auth/google', { | ||
| code, | ||
| uri: redirectUri, | ||
| }); |
There was a problem hiding this comment.
환경 변수 미설정 시 uri가 undefined로 전송되어 API 요청이 실패할 수 있습니다
VITE_GOOGLE_REDIRECT_URI_PROD 또는 VITE_GOOGLE_REDIRECT_URI_DEV가 설정되지 않으면 redirectUri는 undefined가 됩니다. { code, uri: undefined }는 JSON.stringify 시 uri 키가 생략되어 ({"code":"..."}), v3 엔드포인트가 필수 필드 부재로 4xx를 반환하고 catch 블록에서 /onboarding?step=SOCIAL_LOGIN으로 조용히 리다이렉트됩니다.
🛡️ 제안된 수정 — 폴백 & 사전 검증 추가
- const redirectUri = import.meta.env.PROD
- ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD
- : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV;
+ const redirectUri: string | undefined = import.meta.env.PROD
+ ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD
+ : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; const loginWithCode = async (code: string) => {
try {
+ if (!redirectUri) {
+ console.error('Redirect URI가 설정되지 않았습니다.');
+ navigate('/onboarding?step=SOCIAL_LOGIN');
+ return;
+ }
const res = await apiRequest.post('/api/v3/auth/google', {
code,
uri: redirectUri,
});📝 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 redirectUri = import.meta.env.PROD | |
| ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD | |
| : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; | |
| const loginWithCode = async (code: string) => { | |
| try { | |
| const res = await apiRequest.post('/api/v2/auth/google', { code }); | |
| const res = await apiRequest.post('/api/v3/auth/google', { | |
| code, | |
| uri: redirectUri, | |
| }); | |
| const redirectUri: string | undefined = import.meta.env.PROD | |
| ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD | |
| : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; | |
| const loginWithCode = async (code: string) => { | |
| try { | |
| if (!redirectUri) { | |
| console.error('Redirect URI가 설정되지 않았습니다.'); | |
| navigate('/onboarding?step=SOCIAL_LOGIN'); | |
| return; | |
| } | |
| const res = await apiRequest.post('/api/v3/auth/google', { | |
| code, | |
| uri: redirectUri, | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/client/src/pages/onBoarding/GoogleCallback.tsx` around lines 47 - 56,
The redirectUri computed at top (variable redirectUri) can be undefined and is
passed from loginWithCode into apiRequest.post('/api/v3/auth/google', { code,
uri }), causing the v3 endpoint to reject the request; add a safe fallback
(e.g., a known default redirect URL) when computing redirectUri and validate it
at the start of loginWithCode before calling apiRequest.post — if redirectUri is
missing, either populate it with the fallback or throw/handle an explicit error
and avoid sending {uri: undefined} so the API always receives a defined uri
field; update the redirectUri initialization and add a pre-send check in
loginWithCode to enforce this.

📌 Related Issues
📄 Tasks
⭐ PR Point (To Reviewer)
📷 Screenshot
Summary by CodeRabbit
새로운 기능
설명서