diff --git a/.vscode/settings.json b/.vscode/settings.json index fdcdafbf..97233c48 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,8 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": "explicit", + "source.organizeImports": "always" }, "tailwindCSS.suggestions": true, "editor.quickSuggestions": { diff --git a/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx b/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx index 71763bcd..08842f44 100644 --- a/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx +++ b/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx @@ -1,12 +1,12 @@ import { Progress, Button } from '@pinback/design-system/ui'; import { 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')); +import SocialLoginStep from './step/socialLogin/SocialLoginStep'; +const StoryStep = lazy(() => import('./step/story/StoryStep')); +const JobStep = lazy(() => import('./step/job/JobStep')); +const AlarmStep = lazy(() => import('./step/alarm/AlarmStep')); +const MacStep = lazy(() => import('./step/mac/MacStep')); +const FinalStep = lazy(() => import('./step/final/FinalStep')); import { cva } from 'class-variance-authority'; const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }]; import { @@ -47,8 +47,10 @@ const MainCard = () => { direction, alarmSelected, jobShareAgree, + selectedJob, setAlarmSelected, setJobShareAgree, + setSelectedJob, nextStep, prevStep, } = useOnboardingFunnel(); @@ -66,6 +68,8 @@ const MainCard = () => { case Step.JOB: return ( @@ -132,7 +136,7 @@ const MainCard = () => { size="medium" className="ml-auto w-[4.8rem]" onClick={nextStep} - isDisabled={step === Step.JOB && !jobShareAgree} + isDisabled={step === Step.JOB && (!jobShareAgree || !selectedJob)} > 다음 diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx deleted file mode 100644 index 440adb31..00000000 --- a/apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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 = { - planner: jobPlan, - designer: jobDesign, - frontend: jobFrontend, - backend: jobBackend, - }; - - return ( - - ); -}; - -const JobStep = ({ - selectedJob, - onSelectJob, - agreeChecked, - onAgreeChange, -}: JobStepProps) => { - const defaultJob: JobKey = 'planner'; - const [internalJob, setInternalJob] = useState(defaultJob); - const [internalAgree, setInternalAgree] = useState(true); - - 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 ( -
- dotori -
-

직무를 선택해주세요

-

- 직무에 따라 아티클을 추천해드려요 -

-
- -
- {jobs.map((job) => { - const isSelected = activeJob === job.key; - return ( - - ); - })} -
- - -
- ); -}; - -export default JobStep; diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/AlarmStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/alarm/AlarmStep.tsx similarity index 96% rename from apps/client/src/pages/onBoarding/components/funnel/step/AlarmStep.tsx rename to apps/client/src/pages/onBoarding/components/funnel/step/alarm/AlarmStep.tsx index 52694e16..b8b801d2 100644 --- a/apps/client/src/pages/onBoarding/components/funnel/step/AlarmStep.tsx +++ b/apps/client/src/pages/onBoarding/components/funnel/step/alarm/AlarmStep.tsx @@ -1,5 +1,5 @@ import dotori from '/assets/onBoarding/icons/dotori.svg'; -import AlarmBox from '../AlarmBox'; +import AlarmBox from '../../AlarmBox'; import { useEffect } from 'react'; import { sendGAEvent } from '@pinback/design-system/ui'; interface AlarmStepProps { diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/FinalStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/final/FinalStep.tsx similarity index 100% rename from apps/client/src/pages/onBoarding/components/funnel/step/FinalStep.tsx rename to apps/client/src/pages/onBoarding/components/funnel/step/final/FinalStep.tsx diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/job/JobStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/job/JobStep.tsx new file mode 100644 index 00000000..146cd0ae --- /dev/null +++ b/apps/client/src/pages/onBoarding/components/funnel/step/job/JobStep.tsx @@ -0,0 +1,55 @@ +import { Suspense } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import dotori from '/assets/onBoarding/icons/dotori.svg'; +import { Checkbox } from '@pinback/design-system/ui'; +import { JobsResponse } from '@shared/types/api'; +import JobCards from '@shared/components/jobSelectionFunnel/step/job/JobCards'; +import JobCardsSkeleton from '@shared/components/jobSelectionFunnel/step/job/JobCardsSkeleton'; + +interface JobStepProps { + selectedJob: string | null; + onSelectJob: (jobName: string) => void; + agreeChecked: boolean; + onAgreeChange: (checked: boolean) => void; +} + +const JobStep = ({ + selectedJob, + onSelectJob, + agreeChecked, + onAgreeChange, +}: JobStepProps) => { + const queryClient = useQueryClient(); + const cachedJobs = queryClient.getQueryData(['jobs']); + const skeletonCount = cachedJobs?.jobs.length ?? 4; + + return ( +
+ dotori +
+

직무를 선택해주세요

+

+ 직무에 따라 아티클을 추천해드려요 +

+
+ + }> + + + + +
+ ); +}; + +export default JobStep; diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/MacStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/mac/MacStep.tsx similarity index 100% rename from apps/client/src/pages/onBoarding/components/funnel/step/MacStep.tsx rename to apps/client/src/pages/onBoarding/components/funnel/step/mac/MacStep.tsx diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/SocialLoginStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/socialLogin/SocialLoginStep.tsx similarity index 100% rename from apps/client/src/pages/onBoarding/components/funnel/step/SocialLoginStep.tsx rename to apps/client/src/pages/onBoarding/components/funnel/step/socialLogin/SocialLoginStep.tsx diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/StoryStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/story/StoryStep.tsx similarity index 100% rename from apps/client/src/pages/onBoarding/components/funnel/step/StoryStep.tsx rename to apps/client/src/pages/onBoarding/components/funnel/step/story/StoryStep.tsx diff --git a/apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts b/apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts index 571d8333..dbaceae5 100644 --- a/apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts +++ b/apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts @@ -5,7 +5,11 @@ import { useCallback, useEffect, useState } from 'react'; import { AlarmsType } from '@constants/alarms'; import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime'; import { registerServiceWorker } from '@pages/onBoarding/utils/registerServiceWorker'; -import { Step, stepOrder, StepType } from '@pages/onBoarding/constants/onboardingSteps'; +import { + Step, + stepOrder, + StepType, +} from '@pages/onBoarding/constants/onboardingSteps'; import { firebaseConfig } from '../../../firebase-config'; import { getApp, getApps, initializeApp } from 'firebase/app'; import { getMessaging, getToken } from 'firebase/messaging'; @@ -14,11 +18,16 @@ type AlarmSelection = 1 | 2 | 3; export function useOnboardingFunnel() { const { mutate: postSignData } = usePostSignUp(); - const { currentStep: step, currentIndex, setStep, goNext, goPrev } = - useFunnel({ - steps: stepOrder, - initialStep: Step.STORY_0, - }); + const { + currentStep: step, + currentIndex, + setStep, + goNext, + goPrev, + } = useFunnel({ + steps: stepOrder, + initialStep: Step.STORY_0, + }); const [direction, setDirection] = useState(0); const [alarmSelected, setAlarmSelected] = useState(1); @@ -26,7 +35,8 @@ export function useOnboardingFunnel() { const [userEmail, setUserEmail] = useState(''); const [remindTime, setRemindTime] = useState('09:00'); const [fcmToken, setFcmToken] = useState(null); - const [jobShareAgree, setJobShareAgree] = useState(true); + const [jobShareAgree, setJobShareAgree] = useState(false); + const [selectedJob, setSelectedJob] = useState(null); useEffect(() => { const storedEmail = localStorage.getItem('email'); @@ -37,7 +47,8 @@ export function useOnboardingFunnel() { const requestFCMToken = useCallback(async (): Promise => { try { - const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig); + const app = + getApps().length > 0 ? getApp() : initializeApp(firebaseConfig); const messaging = getMessaging(app); const permission = await Notification.requestPermission(); registerServiceWorker(); @@ -149,8 +160,10 @@ export function useOnboardingFunnel() { direction, alarmSelected, jobShareAgree, + selectedJob, setAlarmSelected, setJobShareAgree, + setSelectedJob, nextStep, prevStep, }; diff --git a/apps/client/src/shared/apis/axios.ts b/apps/client/src/shared/apis/axios.ts index 898f4868..9762b213 100644 --- a/apps/client/src/shared/apis/axios.ts +++ b/apps/client/src/shared/apis/axios.ts @@ -1,5 +1,5 @@ import apiRequest from '@shared/apis/setting/axiosInstance'; -import { EditArticleRequest } from '@shared/types/api'; +import { EditArticleRequest, JobsResponse } from '@shared/types/api'; import { formatLocalDateTime } from '@shared/utils/formatDateTime'; export const getDashboardCategories = async () => { @@ -81,3 +81,8 @@ export const getMyProfile = async () => { const { data } = await apiRequest.get('/api/v2/users/me'); return data.data; }; + +export const getJobs = async (): Promise => { + const { data } = await apiRequest.get('/api/v3/enums/jobs'); + return data.data; +}; diff --git a/apps/client/src/shared/apis/queries.ts b/apps/client/src/shared/apis/queries.ts index 33431aaf..4d2f9488 100644 --- a/apps/client/src/shared/apis/queries.ts +++ b/apps/client/src/shared/apis/queries.ts @@ -3,6 +3,7 @@ import { UseMutationResult, useQuery, UseQueryResult, + useSuspenseQuery, } from '@tanstack/react-query'; import { deleteCategory, @@ -18,6 +19,7 @@ import { deleteRemindArticle, getGoogleProfile, getMyProfile, + getJobs, } from '@shared/apis/axios'; import { AxiosError } from 'axios'; import { @@ -26,6 +28,7 @@ import { EditArticleRequest, ArticleReadStatusResponse, ArticleDetailResponse, + JobsResponse, } from '@shared/types/api'; import { fetchOGData } from '@shared/utils/fetchOgData'; @@ -146,9 +149,25 @@ export const useGetGoogleProfile = () => { }); }; -export function useGetMyProfile() { +export const useGetMyProfile = () => { return useQuery({ queryKey: ['myProfile'], queryFn: getMyProfile, }); -} +}; + +export const useGetJobs = (): UseQueryResult => { + return useQuery({ + queryKey: ['jobs'], + queryFn: getJobs, + staleTime: Infinity, + }); +}; + +export const useSuspenseGetJobs = () => { + return useSuspenseQuery({ + queryKey: ['jobs'], + queryFn: getJobs, + staleTime: Infinity, + }); +}; diff --git a/apps/client/src/shared/components/jobSelectionFunnel/JobSelectionFunnel.tsx b/apps/client/src/shared/components/jobSelectionFunnel/JobSelectionFunnel.tsx index ccb4b48d..1806dc4e 100644 --- a/apps/client/src/shared/components/jobSelectionFunnel/JobSelectionFunnel.tsx +++ b/apps/client/src/shared/components/jobSelectionFunnel/JobSelectionFunnel.tsx @@ -2,9 +2,9 @@ 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'; +import JobStep from './step/job/JobStep'; +import PinStep from './step/pin/PinStep'; +import ShareStep from './step/share/ShareStep'; const funnelSteps = ['job', 'pin', 'share'] as const; type FunnelStep = (typeof funnelSteps)[number]; @@ -22,7 +22,7 @@ export default function JobSelectionFunnel({ initialStep: 'job', }); - const [selectedJob, setSelectedJob] = useState('planner'); + const [selectedJob, setSelectedJob] = useState(null); const [jobShareAgree, setJobShareAgree] = useState(false); const handleNext = () => { @@ -59,7 +59,7 @@ export default function JobSelectionFunnel({ size="medium" className="w-[4.8rem]" onClick={handleNext} - isDisabled={currentStep === 'job' && !jobShareAgree} + isDisabled={currentStep === 'job' && (!jobShareAgree || !selectedJob)} > {isLastStep ? '완료' : '다음'} diff --git a/apps/client/src/shared/components/jobSelectionFunnel/step/JobStep.tsx b/apps/client/src/shared/components/jobSelectionFunnel/step/JobStep.tsx deleted file mode 100644 index e37ad31c..00000000 --- a/apps/client/src/shared/components/jobSelectionFunnel/step/JobStep.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 = { - planner: jobPlan, - designer: jobDesign, - frontend: jobFrontend, - backend: jobBackend, - }; - - return ( - - ); -}; - -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 ( -
-
-

직무를 선택해주세요

-

- 직무에 따라 아티클을 추천해드려요 -

-
- -
- {jobs.map((job) => { - const isSelected = selectedJob === job.key; - return ( - - ); - })} -
- - -
- ); -}; - -export default JobStep; diff --git a/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobCards.tsx b/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobCards.tsx new file mode 100644 index 00000000..f13f319f --- /dev/null +++ b/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobCards.tsx @@ -0,0 +1,89 @@ +import { cva } from 'class-variance-authority'; +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 { useSuspenseGetJobs } from '@shared/apis/queries'; + +interface JobCardsProps { + activeJob: string | null; + onSelectJob: (jobName: string) => 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 jobImageByName: Record = { + 기획자: jobPlan, + 디자이너: jobDesign, + 프론트엔드: jobFrontend, + 백엔드: jobBackend, +}; + +const JobIcon = ({ + jobName, + imageUrl, +}: { + jobName: string; + imageUrl: string; +}) => { + const resolvedImageUrl = jobImageByName[jobName] ?? imageUrl; + return ( + + ); +}; + +const JobCards = ({ activeJob, onSelectJob }: JobCardsProps) => { + const { data: jobData } = useSuspenseGetJobs(); + const jobOptions = jobData.jobs; + + return ( +
+ {jobOptions.map(({ imageUrl: jobImageUrl, job: jobName }) => { + const isSelected = activeJob === jobName; + return ( + + ); + })} +
+ ); +}; + +export default JobCards; diff --git a/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobCardsSkeleton.tsx b/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobCardsSkeleton.tsx new file mode 100644 index 00000000..aa5d9a45 --- /dev/null +++ b/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobCardsSkeleton.tsx @@ -0,0 +1,18 @@ +interface JobCardsSkeletonProps { + count?: number; +} + +const JobCardsSkeleton = ({ count = 4 }: JobCardsSkeletonProps) => { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+ ))} +
+ ); +}; + +export default JobCardsSkeleton; diff --git a/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobStep.tsx b/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobStep.tsx new file mode 100644 index 00000000..ba67e8f9 --- /dev/null +++ b/apps/client/src/shared/components/jobSelectionFunnel/step/job/JobStep.tsx @@ -0,0 +1,53 @@ +import { Suspense } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Checkbox } from '@pinback/design-system/ui'; +import { JobsResponse } from '@shared/types/api'; +import JobCards from './JobCards'; +import JobCardsSkeleton from './JobCardsSkeleton'; + +export interface JobStepProps { + selectedJob: string | null; + onSelectJob: (job: string) => void; + agreeChecked: boolean; + onAgreeChange: (checked: boolean) => void; +} + +const JobStep = ({ + selectedJob, + onSelectJob, + agreeChecked, + onAgreeChange, +}: JobStepProps) => { + const queryClient = useQueryClient(); + const cachedJobs = queryClient.getQueryData(['jobs']); + const skeletonCount = cachedJobs?.jobs.length ?? 4; + + return ( +
+
+

직무를 선택해주세요

+

+ 직무에 따라 아티클을 추천해드려요 +

+
+ + }> + + + + +
+ ); +}; + +export default JobStep; diff --git a/apps/client/src/shared/components/jobSelectionFunnel/step/PinStep.tsx b/apps/client/src/shared/components/jobSelectionFunnel/step/pin/PinStep.tsx similarity index 100% rename from apps/client/src/shared/components/jobSelectionFunnel/step/PinStep.tsx rename to apps/client/src/shared/components/jobSelectionFunnel/step/pin/PinStep.tsx diff --git a/apps/client/src/shared/components/jobSelectionFunnel/step/ShareStep.tsx b/apps/client/src/shared/components/jobSelectionFunnel/step/share/ShareStep.tsx similarity index 100% rename from apps/client/src/shared/components/jobSelectionFunnel/step/ShareStep.tsx rename to apps/client/src/shared/components/jobSelectionFunnel/step/share/ShareStep.tsx diff --git a/apps/client/src/shared/types/api.ts b/apps/client/src/shared/types/api.ts index aa3ec6db..e95fd1a8 100644 --- a/apps/client/src/shared/types/api.ts +++ b/apps/client/src/shared/types/api.ts @@ -39,3 +39,12 @@ export interface ArticleDetailResponse { createdAt: string; categoryResponse: CategoryResponse; } + +export interface JobOption { + imageUrl: string; + job: string; +} + +export interface JobsResponse { + jobs: JobOption[]; +}