Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/client/public/assets/onBoarding/jobs/jobBackend.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions apps/client/public/assets/onBoarding/jobs/jobDesign.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions apps/client/public/assets/onBoarding/jobs/jobFrontend.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions apps/client/public/assets/onBoarding/jobs/jobPlan.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion apps/client/src/pages/onBoarding/GoogleCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,16 @@ const GoogleCallback = () => {
}
};

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,
});
Comment on lines +47 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

환경 변수 미설정 시 uriundefined로 전송되어 API 요청이 실패할 수 있습니다

VITE_GOOGLE_REDIRECT_URI_PROD 또는 VITE_GOOGLE_REDIRECT_URI_DEV가 설정되지 않으면 redirectUriundefined가 됩니다. { code, uri: undefined }JSON.stringifyuri 키가 생략되어 ({"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.

Suggested change
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.

const { isUser, userId, email, accessToken } = res.data.data;

localStorage.setItem('email', email);
Expand Down
12 changes: 11 additions & 1 deletion apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useState, useEffect, lazy, Suspense } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import SocialLoginStep from './step/SocialLoginStep';
const StoryStep = lazy(() => import('./step/StoryStep'));
const JobStep = lazy(() => import('./step/JobStep'));
const AlarmStep = lazy(() => import('./step/AlarmStep'));
const MacStep = lazy(() => import('./step/MacStep'));
const FinalStep = lazy(() => import('./step/FinalStep'));
Expand Down Expand Up @@ -36,7 +37,7 @@ const variants = {
};

const CardStyle = cva(
'bg-white-bg flex h-[54.8rem] w-[63.2rem] flex-col items-center justify-between rounded-[2.4rem] pt-[3.2rem]',
'bg-white-bg flex h-[54.8rem] w-full max-w-[82.6rem] flex-col items-center justify-between rounded-[2.4rem] pt-[3.2rem]',
{
variants: {
overflow: {
Expand All @@ -60,6 +61,7 @@ const MainCard = () => {
const [userEmail, setUserEmail] = useState('');
const [remindTime, setRemindTime] = useState('09:00');
const [fcmToken, setFcmToken] = useState<string | null>(null);
const [jobShareAgree, setJobShareAgree] = useState(true);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

jobShareAgree 기본값 true + 미동의 시 진행 불가 = 강제 동의 패턴

jobShareAgreetrue로 초기화되어 동의 체크박스가 사전 선택된 상태로 표시되고, 사용자가 이를 해제하면 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.


useEffect(() => {
const params = new URLSearchParams(location.search);
Expand Down Expand Up @@ -131,6 +133,13 @@ const MainCard = () => {
);
case Step.SOCIAL_LOGIN:
return <SocialLoginStep />;
case Step.JOB:
return (
<JobStep
agreeChecked={jobShareAgree}
onAgreeChange={setJobShareAgree}
/>
);
Comment on lines +136 to +142
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

선택된 직무(job)가 API 호출에 포함되지 않아 기능 목적이 불완전

JobStepselectedJob/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.

case Step.ALARM:
return (
<AlarmStep selected={alarmSelected} setSelected={setAlarmSelected} />
Expand Down Expand Up @@ -246,6 +255,7 @@ const MainCard = () => {
size="medium"
className="ml-auto w-[4.8rem]"
onClick={nextStep}
isDisabled={step === Step.JOB && !jobShareAgree}
>
다음
</Button>
Expand Down
140 changes: 140 additions & 0 deletions apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useState } from 'react';
import { cva } from 'class-variance-authority';
import dotori from '/assets/onBoarding/icons/dotori.svg';
import jobPlan from '/assets/onBoarding/jobs/jobPlan.svg';
import jobDesign from '/assets/onBoarding/jobs/jobDesign.svg';
import jobFrontend from '/assets/onBoarding/jobs/jobFrontend.svg';
import jobBackend from '/assets/onBoarding/jobs/jobBackend.svg';
import { Checkbox } from '@pinback/design-system/ui';

type JobKey = 'planner' | 'designer' | 'frontend' | 'backend';

interface JobStepProps {
selectedJob?: JobKey;
onSelectJob?: (job: JobKey) => void;
agreeChecked?: boolean;
onAgreeChange?: (checked: boolean) => void;
}

const jobCardStyle = cva(
'flex h-[22.4rem] w-[18rem] flex-col items-center justify-center rounded-[1.2rem] border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-main400 focus-visible:ring-offset-2 focus-visible:ring-offset-white-bg',
{
variants: {
selected: {
true: 'border-main400 bg-main0',
false: 'border-transparent bg-white-bg hover:border-main300',
},
},
defaultVariants: { selected: false },
}
);

const JobIcon = ({ type }: { type: JobKey }) => {
const iconMap: Record<JobKey, string> = {
planner: jobPlan,
designer: jobDesign,
frontend: jobFrontend,
backend: jobBackend,
};
Comment on lines +32 to +38
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 따로 타입 분리하는건 어떤가요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 JobIcon은 컴포넌트입니다! JobStep과 많이 연관되어있다고 판단해서 안에 같이 두는 것도 좋을 것 같은데 어떻게 생각하시나요?


return (
<img
src={iconMap[type]}
alt={`${type} 직무 아이콘`}
aria-hidden="true"
className="h-[10.2rem] w-[10.2rem]"
/>
);
};

const JobStep = ({
selectedJob,
onSelectJob,
agreeChecked,
onAgreeChange,
}: JobStepProps) => {
const defaultJob: JobKey = 'planner';
const [internalJob, setInternalJob] = useState<JobKey>(defaultJob);
const [internalAgree, setInternalAgree] = useState(true);
Comment on lines +57 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.


const activeJob = selectedJob ?? internalJob;
const activeAgree = agreeChecked ?? internalAgree;

const jobs: { key: JobKey; label: string }[] = [
{ key: 'planner', label: '기획자' },
{ key: 'designer', label: '디자이너' },
{ key: 'frontend', label: '프론트엔드' },
{ key: 'backend', label: '백엔드' },
];

const handleSelect = (job: JobKey) => {
onSelectJob?.(job);
if (!onSelectJob || selectedJob === undefined) {
setInternalJob(job);
}
};

const handleAgreeChange = (checked: boolean) => {
onAgreeChange?.(checked);
if (!onAgreeChange || agreeChecked === undefined) {
setInternalAgree(checked);
}
};

return (
<div className="flex w-full flex-col items-center">
<img src={dotori} className="mb-[1.2rem]" alt="dotori" />
<div className="mb-[2.4rem] flex flex-col items-center gap-[0.8rem]">
<p className="head3 text-font-black-1">직무를 선택해주세요</p>
<p className="body2-m text-font-gray-3 text-center">
직무에 따라 아티클을 추천해드려요
</p>
</div>

<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)}
className={jobCardStyle({ selected: isSelected })}
>
<div className="flex flex-col items-center gap-[1.6rem]">
<JobIcon type={job.key} />
<span
className={`sub3-sb ${
isSelected ? 'text-main500' : 'text-font-black-1'
}`}
>
{job.label}
</span>
</div>
</button>
);
})}
</div>
Comment on lines 94 to 123
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.


<label className="mt-[2.4rem] flex max-w-[62rem] items-start gap-[1.2rem]">
<Checkbox
size="small"
isSelected={activeAgree}
onSelectedChange={handleAgreeChange}
/>
<span className="body3-r text-font-gray-3">
내가 북마크한 아티클이 내 Google 이름과 함께 다른 사용자에게 추천될 수
있어요.
</span>
</label>
</div>
);
};

export default JobStep;
2 changes: 2 additions & 0 deletions apps/client/src/pages/onBoarding/constants/onboardingSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const Step = {
STORY_1: 'STORY_1',
STORY_2: 'STORY_2',
SOCIAL_LOGIN: 'SOCIAL_LOGIN',
JOB: 'JOB',
ALARM: 'ALARM',
MAC: 'MAC',
FINAL: 'FINAL',
Expand All @@ -21,6 +22,7 @@ export const stepOrder: StepType[] = [
Step.STORY_1,
Step.STORY_2,
Step.SOCIAL_LOGIN,
Step.JOB,
Step.ALARM,
Step.MAC,
Step.FINAL,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Checkbox from './Checkbox';

const meta: Meta<typeof Checkbox> = {
title: 'Components/Checkbox',
component: Checkbox,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'기본 체크박스 컴포넌트입니다. `isSelected`로 제어하고 `onSelectedChange`로 상태를 전달합니다. `size`로 크기를 조절할 수 있습니다.',
},
},
},
argTypes: {
isSelected: { control: 'boolean' },
defaultSelected: { control: 'boolean' },
size: { control: 'inline-radio', options: ['small', 'medium'] },
disabled: { control: 'boolean' },
onSelectedChange: { action: 'selected' },
className: { table: { disable: true } },
},
args: {
size: 'medium',
isSelected: false,
},
};

export default meta;

type Story = StoryObj<typeof Checkbox>;

export const Default: Story = {
args: { isSelected: false },
};

export const Selected: Story = {
args: { isSelected: true },
};

export const Medium: Story = {
args: { size: 'medium', isSelected: true },
};

export const Disabled: Story = {
args: { isSelected: true, disabled: true },
};
Loading
Loading