-
Notifications
You must be signed in to change notification settings - Fork 1
Feat(client): 대시보드 직무 핀 선택 팝업 추가 #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c9e9569
031a26c
bfe025d
0f4e3f5
4867758
8f5eeb2
4796807
23910db
f264068
729c32e
8189571
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(() => { | ||
|
|
@@ -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' | ||
| ); | ||
| const scrollContainerRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const formattedDate = useMemo(() => { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 퍼널 오버레이: 접근성(aria-modal) + 포커스/스크롤 관리가 필요 Line 250-259는 “모달” 성격인데 🤖 Prompt for AI Agents |
||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
퍼센트는 clamp( Line 10 )되어 안전한데, step 활성화는 제안(예시): 표시용 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 |
||
| 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); | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f \( -name "*.ts" -o -name "*.tsx" \) -path "*/packages/*" | head -20Repository: 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 5Repository: 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.tsxRepository: Pinback-Team/pinback-client Length of output: 3233 🏁 Script executed: cat packages/design-system/src/components/checkbox/Checkbox.stories.tsxRepository: Pinback-Team/pinback-client Length of output: 1223 라벨 결합 접근성 개선 필요
Checkbox는 내부에 native
현재 암묵적 연결도 작동하지만, 향후 DOM 구조 변경에 취약할 수 있습니다. 🤖 Prompt for AI Agents |
||
| </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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
localStorage를 state 초기화에서 바로 읽는 패턴은 SSR/프리렌더에서 터질 수 있음Line 38-40은 CSR-only면 문제 없지만, SSR/프리렌더/스토리북 환경에선
localStorage가 undefined라 런타임 에러가 날 수 있어요. 방어적으로typeof window !== 'undefined'가드(또는 effect에서 setState)를 두는 편이 안전합니다.제안(예시): window 가드
📝 Committable suggestion
🤖 Prompt for AI Agents