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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 7 additions & 3 deletions apps/client/src/pages/onBoarding/GoogleCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const GoogleCallback = () => {

const handleUserLogin = (
isUser: boolean,
accessToken: string | undefined
accessToken: string | undefined,
hasJob?: boolean
) => {
if (isUser) {
if (accessToken) {
Expand All @@ -38,6 +39,9 @@ const GoogleCallback = () => {
};
sendTokenToExtension(accessToken);
}
if (typeof hasJob === 'boolean') {
localStorage.setItem('hasJob', String(hasJob));
}
navigate('/');
} else {
navigate('/onboarding?step=ALARM');
Expand All @@ -54,12 +58,12 @@ const GoogleCallback = () => {
code,
uri: redirectUri,
});
const { isUser, userId, email, accessToken } = res.data.data;
const { isUser, userId, email, accessToken, hasJob } = res.data.data;

localStorage.setItem('email', email);
localStorage.setItem('userId', userId);

handleUserLogin(isUser, accessToken);
handleUserLogin(isUser, accessToken, hasJob);
} catch (error) {
console.error('로그인 오류:', error);
navigate('/onboarding?step=SOCIAL_LOGIN');
Expand Down
15 changes: 15 additions & 0 deletions apps/client/src/pages/remind/Remind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import FetchCard from './components/fetchCard/FetchCard';
import { useInfiniteScroll } from '@shared/hooks/useInfiniteScroll';
import Tooltip from '@shared/components/tooltip/Tooltip';
import Footer from './components/footer/Footer';
import JobSelectionFunnel from '@shared/components/jobSelectionFunnel/JobSelectionFunnel';

const Remind = () => {
useEffect(() => {
Expand All @@ -34,6 +35,9 @@ const Remind = () => {
const [activeBadge, setActiveBadge] = useState<'read' | 'notRead'>('notRead');
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [showJobSelectionFunnel, setShowJobSelectionFunnel] = useState(
() => localStorage.getItem('hasJob') !== 'true'
);
Comment on lines 38 to 40
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

localStorage를 state 초기화에서 바로 읽는 패턴은 SSR/프리렌더에서 터질 수 있음

Line 38-40은 CSR-only면 문제 없지만, SSR/프리렌더/스토리북 환경에선 localStorage가 undefined라 런타임 에러가 날 수 있어요. 방어적으로 typeof window !== 'undefined' 가드(또는 effect에서 setState)를 두는 편이 안전합니다.

제안(예시): window 가드
-  const [showJobSelectionFunnel, setShowJobSelectionFunnel] = useState(
-    () => localStorage.getItem('hasJob') === 'false'
-  );
+  const [showJobSelectionFunnel, setShowJobSelectionFunnel] = useState(() => {
+    if (typeof window === 'undefined') return false;
+    return window.localStorage.getItem('hasJob') === '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 [showJobSelectionFunnel, setShowJobSelectionFunnel] = useState(
() => localStorage.getItem('hasJob') === 'false'
);
const [showJobSelectionFunnel, setShowJobSelectionFunnel] = useState(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem('hasJob') === 'false';
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/remind/Remind.tsx` around lines 38 - 40, The state
initializer for showJobSelectionFunnel reads localStorage directly which will
crash in SSR/prerender/storybook; update Remind.tsx to avoid accessing
localStorage during render by either guarding with typeof window !== 'undefined'
inside the useState initializer or initialize with a safe default (e.g., false)
and then read localStorage inside a useEffect that calls
setShowJobSelectionFunnel; touch the useState call and the
setShowJobSelectionFunnel updates to ensure no direct localStorage access
happens during server render.

const scrollContainerRef = useRef<HTMLDivElement>(null);

const formattedDate = useMemo(() => {
Expand Down Expand Up @@ -242,6 +246,17 @@ const Remind = () => {
</div>
</div>
)}

{showJobSelectionFunnel && (
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/40 p-4">
<JobSelectionFunnel
onComplete={() => {
// TODO: 관심 직무 핀 API 연동 필요
setShowJobSelectionFunnel(false);
}}
/>
</div>
)}
Comment on lines 250 to 259
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

퍼널 오버레이: 접근성(aria-modal) + 포커스/스크롤 관리가 필요

Line 250-259는 “모달” 성격인데 role="dialog", aria-modal="true", 포커스 트랩, ESC 닫기, 배경 스크롤 락 등이 없어서 키보드/스크린리더 사용 시 이탈 가능성이 큽니다. 가능하면 기존 PopupContainer처럼 모달 primitive(있다면)로 감싸는 쪽이 안전해요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/remind/Remind.tsx` around lines 250 - 259, The
JobSelectionFunnel overlay lacks modal semantics and focus/scroll management;
wrap the conditional JSX that renders JobSelectionFunnel (controlled by
showJobSelectionFunnel and closed via setShowJobSelectionFunnel) in a proper
modal primitive (e.g., reuse PopupContainer if available) or add role="dialog"
and aria-modal="true" to the container, implement a focus trap that moves focus
into the funnel on open and returns focus on close, add an ESC key handler to
call setShowJobSelectionFunnel(false), and lock background scrolling while the
modal is open; also ensure the onComplete path (which sets localStorage.hasJob)
triggers the same close and focus/scroll cleanup.

</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { cn } from '@pinback/design-system/utils';

interface FunnelProgressProps {
currentIndex: number;
totalSteps: number;
}

const FunnelProgress = ({ currentIndex, totalSteps }: FunnelProgressProps) => {
const maxIndex = Math.max(1, totalSteps - 1);
const percent = Math.max(0, Math.min(100, (currentIndex / maxIndex) * 100));

Comment on lines +8 to +11
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

currentIndex 범위 클램프(퍼센트뿐 아니라 step 활성화도) 권장

퍼센트는 clamp( Line 10 )되어 안전한데, step 활성화는 index <= currentIndex( Line 22 )라서 currentIndex-1이거나 totalSteps 이상이면 동그라미 상태가 깨질 수 있어요(예: 전부 active). 상위에서 항상 보장한다면 OK지만, 컴포넌트 단에서 방어하는 편이 안전합니다.

제안(예시): 표시용 index를 별도로 clamp
 const FunnelProgress = ({ currentIndex, totalSteps }: FunnelProgressProps) => {
   const maxIndex = Math.max(1, totalSteps - 1);
-  const percent = Math.max(0, Math.min(100, (currentIndex / maxIndex) * 100));
+  const safeIndex = Math.max(0, Math.min(currentIndex, Math.max(0, totalSteps - 1)));
+  const percent = Math.max(0, Math.min(100, (safeIndex / maxIndex) * 100));
...
-          const isActive = index <= currentIndex;
+          const isActive = index <= safeIndex;

Also applies to: 21-36

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/shared/components/jobSelectionFunnel/FunnelProgress.tsx`
around lines 8 - 11, FunnelProgress currently clamps percent but not the
step-activation logic, so if currentIndex is <0 or >= totalSteps the "active"
circles break; introduce a clamped display index (e.g., displayedIndex =
Math.max(0, Math.min(totalSteps - 1, currentIndex))) and use that displayedIndex
for both the percent calculation and the step active checks (replace usages of
currentIndex in percent and the `index <= currentIndex` checks) while retaining
maxIndex for division safety.

return (
<div className="relative flex h-[2rem] w-[26.9rem] items-center justify-center">
<div className="bg-gray100 absolute left-[0.6rem] right-[0.6rem] top-1/2 h-[0.7rem] -translate-y-1/2 rounded-full">
<div
className="bg-main400 h-full rounded-full transition-[width] duration-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="relative z-10 flex w-full items-center justify-between">
{Array.from({ length: totalSteps }).map((_, index) => {
const isActive = index <= currentIndex;
return (
<div
key={`funnel-progress-${index}`}
className={cn(
'flex size-[2rem] items-center justify-center rounded-full text-[1.2rem] font-semibold leading-[1.5]',
isActive
? 'bg-main400 text-white'
: 'bg-gray100 text-font-ltgray-4'
)}
>
{index + 1}
</div>
);
})}
</div>
</div>
);
};

export default FunnelProgress;
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Button } from '@pinback/design-system/ui';
import { useFunnel } from '@shared/hooks/useFunnel';
import { useState } from 'react';
import FunnelProgress from './FunnelProgress';
import JobStep, { JobKey } from './step/JobStep';
import PinStep from './step/PinStep';
import ShareStep from './step/ShareStep';

const funnelSteps = ['job', 'pin', 'share'] as const;
type FunnelStep = (typeof funnelSteps)[number];

interface JobSelectionFunnelProps {
onComplete?: () => void;
}

export default function JobSelectionFunnel({
onComplete,
}: JobSelectionFunnelProps) {
const { currentStep, currentIndex, goNext, isLastStep } =
useFunnel<FunnelStep>({
steps: funnelSteps,
initialStep: 'job',
});

const [selectedJob, setSelectedJob] = useState<JobKey>('planner');
const [jobShareAgree, setJobShareAgree] = useState(false);

const handleNext = () => {
if (isLastStep) {
onComplete?.();
return;
}
goNext();
};

return (
<section className="bg-white-bg flex h-[54.8rem] w-full max-w-[82.6rem] flex-col items-center justify-between rounded-[2.4rem] px-[3.2rem] pb-[4.8rem] pt-[3.2rem]">
<FunnelProgress
currentIndex={currentIndex}
totalSteps={funnelSteps.length}
/>

<div className="flex h-full w-full items-center justify-center">
{currentStep === 'job' && (
<JobStep
selectedJob={selectedJob}
onSelectJob={setSelectedJob}
agreeChecked={jobShareAgree}
onAgreeChange={setJobShareAgree}
/>
)}
{currentStep === 'pin' && <PinStep />}
{currentStep === 'share' && <ShareStep />}
</div>

<div className="flex w-full justify-end">
<Button
variant="primary"
size="medium"
className="w-[4.8rem]"
onClick={handleNext}
isDisabled={currentStep === 'job' && !jobShareAgree}
>
{isLastStep ? '완료' : '다음'}
</Button>
</div>
</section>
);
}
113 changes: 113 additions & 0 deletions apps/client/src/shared/components/jobSelectionFunnel/step/JobStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Checkbox } from '@pinback/design-system/ui';
import { cn } from '@pinback/design-system/utils';
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';

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

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

const jobCardStyle = (selected: boolean) =>
cn(
'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',
selected
? 'border-main400 bg-main0'
: 'border-transparent bg-white-bg hover:border-main300'
);

const JobIcon = ({ type }: { type: JobKey }) => {
const iconMap: Record<JobKey, string> = {
planner: jobPlan,
designer: jobDesign,
frontend: jobFrontend,
backend: jobBackend,
};

return (
<img
src={iconMap[type]}
alt=""
aria-hidden="true"
className="h-[10.2rem] w-[10.2rem]"
/>
);
};

const JobStep = ({
selectedJob,
onSelectJob,
agreeChecked,
onAgreeChange,
}: JobStepProps) => {
const jobs: { key: JobKey; label: string }[] = [
{ key: 'planner', label: '기획자' },
{ key: 'designer', label: '디자이너' },
{ key: 'frontend', label: '프론트엔드 개발자' },
{ key: 'backend', label: '백엔드 개발자' },
];

return (
<div className="flex w-full flex-col items-center">
<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 = selectedJob === job.key;
return (
<button
key={job.key}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onSelectJob(job.key)}
className={jobCardStyle(isSelected)}
>
<div className="flex flex-col items-center gap-[1.6rem]">
<JobIcon type={job.key} />
<span
className={cn(
'sub3-sb text-center',
isSelected ? 'text-main500' : 'text-font-black-1'
)}
>
{job.label}
</span>
</div>
</button>
);
})}
</div>

<label className="mt-[2.4rem] flex max-w-[62rem] items-start gap-[1.2rem]">
<Checkbox
size="small"
isSelected={agreeChecked}
onSelectedChange={onAgreeChange}
/>
<span className="body3-r text-font-gray-3">
내가 북마크한 아티클이 내 Google 이름과 함께 다른 사용자에게 추천될 수
있어요.
</span>
</label>
Comment on lines +98 to +108
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

🧩 Analysis chain

🏁 Script executed:

find . -type f \( -name "*.ts" -o -name "*.tsx" \) -path "*/packages/*" | head -20

Repository: Pinback-Team/pinback-client

Length of output: 1275


🏁 Script executed:

rg -n --type=ts --type=tsx "export (function|const) Checkbox|function Checkbox|const Checkbox" packages -A 5

Repository: Pinback-Team/pinback-client

Length of output: 98


🏁 Script executed:

fd "Checkbox" packages -t f \( -name "*.ts" -o -name "*.tsx" \)

Repository: Pinback-Team/pinback-client

Length of output: 301


🏁 Script executed:

cat packages/design-system/src/components/checkbox/Checkbox.tsx

Repository: Pinback-Team/pinback-client

Length of output: 3233


🏁 Script executed:

cat packages/design-system/src/components/checkbox/Checkbox.stories.tsx

Repository: Pinback-Team/pinback-client

Length of output: 1223


라벨 결합 접근성 개선 필요

<label> 태그로 Checkbox를 감싸는 방식은 암묵적 라벨 연결(implicit association)이 작동하지만, Checkbox 컴포넌트가 ID를 외부로 export하지 않아 명시적 연결(htmlFor)을 사용할 수 없습니다.

Checkbox는 내부에 native <input type="checkbox" />를 가지고 있으나, 컴포넌트 ID를 접근할 방법이 없으므로 다음 중 하나를 권장합니다:

  • Checkbox 컴포넌트에 ID export 메서드 추가 (예: useCheckboxId() hook이나 id 반환)
  • 또는 라벨 텍스트를 aria-label / aria-labelledby로 처리

현재 암묵적 연결도 작동하지만, 향후 DOM 구조 변경에 취약할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/shared/components/jobSelectionFunnel/step/JobStep.tsx` around
lines 100 - 110, The implicit label wrapping the Checkbox in JobStep.tsx is
fragile; update the Checkbox component to accept an id prop (or provide a
useCheckboxId hook) and forward that id to its internal <input>; then in
JobStep, generate a stable unique id, pass it as id to Checkbox (used with
agreeChecked and onAgreeChange), and change the markup so the text uses an
explicit htmlFor linking to that id (or apply aria-labelledby referencing that
id) to ensure durable, explicit label-input association.

</div>
);
};

export default JobStep;
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pinStepImage from '/assets/jobSelectionFunnel/pinStep_description.svg';

const PinStep = () => {
return (
<div className="flex w-full flex-col items-center">
<div className="mb-[2.8rem] 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 className="flex items-center justify-center">
<img
src={pinStepImage}
alt="관심 직무 핀 안내"
className="h-auto max-h-[28rem] w-[41.8rem] max-w-full object-contain"
/>
</div>
</div>
);
};

export default PinStep;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import shareStepImage from '/assets/jobSelectionFunnel/shareStep_description.svg';

const ShareStep = () => {
return (
<div className="flex w-full flex-col items-center">
<div className="mb-[4.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 className="flex w-full items-center justify-center">
<img
src={shareStepImage}
alt="카테고리 공유 안내"
className="h-auto w-[57.6rem] max-w-full"
/>
</div>
</div>
);
};

export default ShareStep;
Loading