Skip to content

Commit 7a16378

Browse files
Feat(client): 대시보드 직무 핀 선택 팝업 추가 (#266)
* feat: funnel progress bar 구현 * feat: job selelction funnel 구현 * feat: job funnel remind page에 추가 * feat: login handler 함수에 hasJob localstorage 저장 로직 추가 * chore: progress bar step number font 변경 * fix: jobShareAgree default value false로 변경 * refactor: hasJob type guard boolean 비교로 변경 * fix: image path import로 수정 * fix: funnel show default value 조건 수정 * chore: 불필요 디자인 요소 제거 * chore: 불필요 import문 삭제
1 parent 8df6910 commit 7a16378

File tree

9 files changed

+397
-3
lines changed

9 files changed

+397
-3
lines changed
Lines changed: 23 additions & 0 deletions
Loading

apps/client/public/assets/jobSelectionFunnel/shareStep_description.svg

Lines changed: 75 additions & 0 deletions
Loading

apps/client/src/pages/onBoarding/GoogleCallback.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const GoogleCallback = () => {
2121

2222
const handleUserLogin = (
2323
isUser: boolean,
24-
accessToken: string | undefined
24+
accessToken: string | undefined,
25+
hasJob?: boolean
2526
) => {
2627
if (isUser) {
2728
if (accessToken) {
@@ -38,6 +39,9 @@ const GoogleCallback = () => {
3839
};
3940
sendTokenToExtension(accessToken);
4041
}
42+
if (typeof hasJob === 'boolean') {
43+
localStorage.setItem('hasJob', String(hasJob));
44+
}
4145
navigate('/');
4246
} else {
4347
navigate('/onboarding?step=ALARM');
@@ -54,12 +58,12 @@ const GoogleCallback = () => {
5458
code,
5559
uri: redirectUri,
5660
});
57-
const { isUser, userId, email, accessToken } = res.data.data;
61+
const { isUser, userId, email, accessToken, hasJob } = res.data.data;
5862

5963
localStorage.setItem('email', email);
6064
localStorage.setItem('userId', userId);
6165

62-
handleUserLogin(isUser, accessToken);
66+
handleUserLogin(isUser, accessToken, hasJob);
6367
} catch (error) {
6468
console.error('로그인 오류:', error);
6569
navigate('/onboarding?step=SOCIAL_LOGIN');

apps/client/src/pages/remind/Remind.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import FetchCard from './components/fetchCard/FetchCard';
2424
import { useInfiniteScroll } from '@shared/hooks/useInfiniteScroll';
2525
import TooltipCard from '@shared/components/tooltipCard/TooltipCard';
2626
import Footer from './components/footer/Footer';
27+
import JobSelectionFunnel from '@shared/components/jobSelectionFunnel/JobSelectionFunnel';
2728

2829
const Remind = () => {
2930
useEffect(() => {
@@ -34,6 +35,9 @@ const Remind = () => {
3435
const [activeBadge, setActiveBadge] = useState<'read' | 'notRead'>('notRead');
3536
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
3637
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
38+
const [showJobSelectionFunnel, setShowJobSelectionFunnel] = useState(
39+
() => localStorage.getItem('hasJob') !== 'true'
40+
);
3741
const scrollContainerRef = useRef<HTMLDivElement>(null);
3842

3943
const formattedDate = useMemo(() => {
@@ -242,6 +246,17 @@ const Remind = () => {
242246
</div>
243247
</div>
244248
)}
249+
250+
{showJobSelectionFunnel && (
251+
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/40 p-4">
252+
<JobSelectionFunnel
253+
onComplete={() => {
254+
// TODO: 관심 직무 핀 API 연동 필요
255+
setShowJobSelectionFunnel(false);
256+
}}
257+
/>
258+
</div>
259+
)}
245260
</div>
246261
);
247262
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { cn } from '@pinback/design-system/utils';
2+
3+
interface FunnelProgressProps {
4+
currentIndex: number;
5+
totalSteps: number;
6+
}
7+
8+
const FunnelProgress = ({ currentIndex, totalSteps }: FunnelProgressProps) => {
9+
const maxIndex = Math.max(1, totalSteps - 1);
10+
const percent = Math.max(0, Math.min(100, (currentIndex / maxIndex) * 100));
11+
12+
return (
13+
<div className="relative flex h-[2rem] w-[26.9rem] items-center justify-center">
14+
<div className="bg-gray100 absolute left-[0.6rem] right-[0.6rem] top-1/2 h-[0.7rem] -translate-y-1/2 rounded-full">
15+
<div
16+
className="bg-main400 h-full rounded-full transition-[width] duration-500"
17+
style={{ width: `${percent}%` }}
18+
/>
19+
</div>
20+
<div className="relative z-10 flex w-full items-center justify-between">
21+
{Array.from({ length: totalSteps }).map((_, index) => {
22+
const isActive = index <= currentIndex;
23+
return (
24+
<div
25+
key={`funnel-progress-${index}`}
26+
className={cn(
27+
'flex size-[2rem] items-center justify-center rounded-full text-[1.2rem] font-semibold leading-[1.5]',
28+
isActive
29+
? 'bg-main400 text-white'
30+
: 'bg-gray100 text-font-ltgray-4'
31+
)}
32+
>
33+
{index + 1}
34+
</div>
35+
);
36+
})}
37+
</div>
38+
</div>
39+
);
40+
};
41+
42+
export default FunnelProgress;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Button } from '@pinback/design-system/ui';
2+
import { useFunnel } from '@shared/hooks/useFunnel';
3+
import { useState } from 'react';
4+
import FunnelProgress from './FunnelProgress';
5+
import JobStep, { JobKey } from './step/JobStep';
6+
import PinStep from './step/PinStep';
7+
import ShareStep from './step/ShareStep';
8+
9+
const funnelSteps = ['job', 'pin', 'share'] as const;
10+
type FunnelStep = (typeof funnelSteps)[number];
11+
12+
interface JobSelectionFunnelProps {
13+
onComplete?: () => void;
14+
}
15+
16+
export default function JobSelectionFunnel({
17+
onComplete,
18+
}: JobSelectionFunnelProps) {
19+
const { currentStep, currentIndex, goNext, isLastStep } =
20+
useFunnel<FunnelStep>({
21+
steps: funnelSteps,
22+
initialStep: 'job',
23+
});
24+
25+
const [selectedJob, setSelectedJob] = useState<JobKey>('planner');
26+
const [jobShareAgree, setJobShareAgree] = useState(false);
27+
28+
const handleNext = () => {
29+
if (isLastStep) {
30+
onComplete?.();
31+
return;
32+
}
33+
goNext();
34+
};
35+
36+
return (
37+
<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]">
38+
<FunnelProgress
39+
currentIndex={currentIndex}
40+
totalSteps={funnelSteps.length}
41+
/>
42+
43+
<div className="flex h-full w-full items-center justify-center">
44+
{currentStep === 'job' && (
45+
<JobStep
46+
selectedJob={selectedJob}
47+
onSelectJob={setSelectedJob}
48+
agreeChecked={jobShareAgree}
49+
onAgreeChange={setJobShareAgree}
50+
/>
51+
)}
52+
{currentStep === 'pin' && <PinStep />}
53+
{currentStep === 'share' && <ShareStep />}
54+
</div>
55+
56+
<div className="flex w-full justify-end">
57+
<Button
58+
variant="primary"
59+
size="medium"
60+
className="w-[4.8rem]"
61+
onClick={handleNext}
62+
isDisabled={currentStep === 'job' && !jobShareAgree}
63+
>
64+
{isLastStep ? '완료' : '다음'}
65+
</Button>
66+
</div>
67+
</section>
68+
);
69+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Checkbox } from '@pinback/design-system/ui';
2+
import { cn } from '@pinback/design-system/utils';
3+
import jobPlan from '/assets/onBoarding/jobs/jobPlan.svg';
4+
import jobDesign from '/assets/onBoarding/jobs/jobDesign.svg';
5+
import jobFrontend from '/assets/onBoarding/jobs/jobFrontend.svg';
6+
import jobBackend from '/assets/onBoarding/jobs/jobBackend.svg';
7+
8+
export type JobKey = 'planner' | 'designer' | 'frontend' | 'backend';
9+
10+
export interface JobStepProps {
11+
selectedJob: JobKey;
12+
onSelectJob: (job: JobKey) => void;
13+
agreeChecked: boolean;
14+
onAgreeChange: (checked: boolean) => void;
15+
}
16+
17+
const jobCardStyle = (selected: boolean) =>
18+
cn(
19+
'flex h-[22.4rem] w-[18rem] flex-col items-center justify-center rounded-[1.2rem] border transition',
20+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-main400 focus-visible:ring-offset-2 focus-visible:ring-offset-white-bg',
21+
selected
22+
? 'border-main400 bg-main0'
23+
: 'border-transparent bg-white-bg hover:border-main300'
24+
);
25+
26+
const JobIcon = ({ type }: { type: JobKey }) => {
27+
const iconMap: Record<JobKey, string> = {
28+
planner: jobPlan,
29+
designer: jobDesign,
30+
frontend: jobFrontend,
31+
backend: jobBackend,
32+
};
33+
34+
return (
35+
<img
36+
src={iconMap[type]}
37+
alt=""
38+
aria-hidden="true"
39+
className="h-[10.2rem] w-[10.2rem]"
40+
/>
41+
);
42+
};
43+
44+
const JobStep = ({
45+
selectedJob,
46+
onSelectJob,
47+
agreeChecked,
48+
onAgreeChange,
49+
}: JobStepProps) => {
50+
const jobs: { key: JobKey; label: string }[] = [
51+
{ key: 'planner', label: '기획자' },
52+
{ key: 'designer', label: '디자이너' },
53+
{ key: 'frontend', label: '프론트엔드 개발자' },
54+
{ key: 'backend', label: '백엔드 개발자' },
55+
];
56+
57+
return (
58+
<div className="flex w-full flex-col items-center">
59+
<div className="mb-[2.4rem] flex flex-col items-center gap-[0.8rem]">
60+
<p className="head3 text-font-black-1">직무를 선택해주세요</p>
61+
<p className="body2-m text-font-gray-3 text-center">
62+
직무에 따라 아티클을 추천해드려요
63+
</p>
64+
</div>
65+
66+
<div
67+
role="radiogroup"
68+
aria-label="직무 선택"
69+
className="grid w-full grid-cols-2 justify-items-center gap-[1.4rem] sm:grid-cols-4"
70+
>
71+
{jobs.map((job) => {
72+
const isSelected = selectedJob === job.key;
73+
return (
74+
<button
75+
key={job.key}
76+
type="button"
77+
role="radio"
78+
aria-checked={isSelected}
79+
onClick={() => onSelectJob(job.key)}
80+
className={jobCardStyle(isSelected)}
81+
>
82+
<div className="flex flex-col items-center gap-[1.6rem]">
83+
<JobIcon type={job.key} />
84+
<span
85+
className={cn(
86+
'sub3-sb text-center',
87+
isSelected ? 'text-main500' : 'text-font-black-1'
88+
)}
89+
>
90+
{job.label}
91+
</span>
92+
</div>
93+
</button>
94+
);
95+
})}
96+
</div>
97+
98+
<label className="mt-[2.4rem] flex max-w-[62rem] items-start gap-[1.2rem]">
99+
<Checkbox
100+
size="small"
101+
isSelected={agreeChecked}
102+
onSelectedChange={onAgreeChange}
103+
/>
104+
<span className="body3-r text-font-gray-3">
105+
내가 북마크한 아티클이 내 Google 이름과 함께 다른 사용자에게 추천될 수
106+
있어요.
107+
</span>
108+
</label>
109+
</div>
110+
);
111+
};
112+
113+
export default JobStep;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pinStepImage from '/assets/jobSelectionFunnel/pinStep_description.svg';
2+
3+
const PinStep = () => {
4+
return (
5+
<div className="flex w-full flex-col items-center">
6+
<div className="mb-[2.8rem] flex flex-col items-center gap-[0.8rem]">
7+
<p className="head3 text-font-black-1">
8+
관심 직무 핀이 새롭게 개설되었어요!
9+
</p>
10+
<p className="body2-m text-font-gray-3 text-center">
11+
우측 사이드바를 통해 탐색해보세요
12+
</p>
13+
</div>
14+
15+
<div className="flex items-center justify-center">
16+
<img
17+
src={pinStepImage}
18+
alt="관심 직무 핀 안내"
19+
className="h-auto max-h-[28rem] w-[41.8rem] max-w-full object-contain"
20+
/>
21+
</div>
22+
</div>
23+
);
24+
};
25+
26+
export default PinStep;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import shareStepImage from '/assets/jobSelectionFunnel/shareStep_description.svg';
2+
3+
const ShareStep = () => {
4+
return (
5+
<div className="flex w-full flex-col items-center">
6+
<div className="mb-[4.4rem] flex flex-col items-center gap-[0.8rem]">
7+
<p className="head3 text-font-black-1">
8+
내가 저장한 아티클을 공유할 수 있어요
9+
</p>
10+
<p className="body2-m text-font-gray-3 text-center">
11+
카테고리를 공유해 다른 사용자들에게 내가 저장한 좋은 아티클을 보여줄
12+
수 있어요
13+
</p>
14+
</div>
15+
16+
<div className="flex w-full items-center justify-center">
17+
<img
18+
src={shareStepImage}
19+
alt="카테고리 공유 안내"
20+
className="h-auto w-[57.6rem] max-w-full"
21+
/>
22+
</div>
23+
</div>
24+
);
25+
};
26+
27+
export default ShareStep;

0 commit comments

Comments
 (0)