Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
19 changes: 17 additions & 2 deletions 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,15 +37,19 @@ 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 flex-col items-center justify-between rounded-[2.4rem] pt-[3.2rem]',
{
variants: {
overflow: {
true: 'overflow-visible',
false: 'overflow-hidden',
},
size: {
default: 'max-w-[63.2rem]',
wide: 'max-w-[82.6rem]',
},
},
defaultVariants: { overflow: false },
defaultVariants: { overflow: false, size: 'default' },
}
);

Expand All @@ -60,6 +65,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 +137,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 @@ -201,6 +214,7 @@ const MainCard = () => {
<div
className={CardStyle({
overflow: step === Step.ALARM && alarmSelected === 3,
size: step === Step.JOB ? 'wide' : 'default',
Copy link
Member Author

Choose a reason for hiding this comment

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

Image

직무 step이 다른 알람 step과 다르게 더 넓은 width값을 가지고 있어서 이를 분기처리하긴했어요.

처음에는 container에 width값을 안주고 내부 요소에 width를 설정해서 조절하도록 할까 했는데, 맥북 알림/리마인드 시간 선택 step등이 내부는 다른데 container는 63.2rem으로 동일해서 일단 저렇게 wide값을 가질때를 분기했어요.

사실 저렇게 하면 step이 바뀔 때 container의 width값이 변경되니 CLS(레이아웃 변경 횟수) 평가 지표에 좋지 않은 영향이 갈 것이라고 생각해요. 그래서 일단 디자이너에게 통일할 수 있는 방법을 물어본 상태이고, 만약 안된다면 layout 변경을 최소화하고 최적화 할 수 있는지 체크해볼게요!

})}
>
{storySteps.includes(step) && (
Expand Down Expand Up @@ -246,6 +260,7 @@ const MainCard = () => {
size="medium"
className="ml-auto w-[4.8rem]"
onClick={nextStep}
isDisabled={step === Step.JOB && !jobShareAgree}
>
다음
</Button>
Expand Down
148 changes: 148 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,148 @@
import { useId, useMemo, 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 agreeId = useId();
Copy link
Collaborator

Choose a reason for hiding this comment

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

useId는 어떻게 동작하나요? 이게 왜 필요한지 궁금합니다

Copy link
Member Author

Choose a reason for hiding this comment

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

useId는 고유 ID를 생성하기 위한 React Hook이에요.
어트리뷰트에 unique한 값을 넣을 때 보통 사용하는데, 해당 jobStep에서도 label태그의 htmlFor와 input의 id에 같은 값을 연결하기 위해 직접 값을 입력하는 것이 아닌 useId를 사용했어요!

다만 지금 생각해보니 label 태그 안에 input을 넣을 경우는 굳이 htmlFor로 연결을 안해도 되는 것 같아서 해당 useId는 제거할게요!

88232ae


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

const jobs = useMemo(
Copy link
Collaborator

Choose a reason for hiding this comment

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

여기서 왜 useMemo가 필요하나요?

Copy link
Member Author

Choose a reason for hiding this comment

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

불필요한 useMemo 같네요! 제거 완완입니다~

() => [
{ 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 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>
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
htmlFor={agreeId}
className="mt-[2.4rem] flex max-w-[62rem] items-start gap-[1.2rem]"
>
<Checkbox
id={agreeId}
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