diff --git a/apps/client/public/assets/onBoarding/jobs/jobBackend.svg b/apps/client/public/assets/onBoarding/jobs/jobBackend.svg new file mode 100644 index 00000000..89909531 --- /dev/null +++ b/apps/client/public/assets/onBoarding/jobs/jobBackend.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/client/public/assets/onBoarding/jobs/jobDesign.svg b/apps/client/public/assets/onBoarding/jobs/jobDesign.svg new file mode 100644 index 00000000..a76eabaf --- /dev/null +++ b/apps/client/public/assets/onBoarding/jobs/jobDesign.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/client/public/assets/onBoarding/jobs/jobFrontend.svg b/apps/client/public/assets/onBoarding/jobs/jobFrontend.svg new file mode 100644 index 00000000..db561232 --- /dev/null +++ b/apps/client/public/assets/onBoarding/jobs/jobFrontend.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/client/public/assets/onBoarding/jobs/jobPlan.svg b/apps/client/public/assets/onBoarding/jobs/jobPlan.svg new file mode 100644 index 00000000..c46d4772 --- /dev/null +++ b/apps/client/public/assets/onBoarding/jobs/jobPlan.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/client/src/pages/onBoarding/GoogleCallback.tsx b/apps/client/src/pages/onBoarding/GoogleCallback.tsx index e7402f14..db9f6344 100644 --- a/apps/client/src/pages/onBoarding/GoogleCallback.tsx +++ b/apps/client/src/pages/onBoarding/GoogleCallback.tsx @@ -44,9 +44,16 @@ const GoogleCallback = () => { } }; + const redirectUri = import.meta.env.PROD + ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD + : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; + const loginWithCode = async (code: string) => { try { - const res = await apiRequest.post('/api/v2/auth/google', { code }); + const res = await apiRequest.post('/api/v3/auth/google', { + code, + uri: redirectUri, + }); const { isUser, userId, email, accessToken } = res.data.data; localStorage.setItem('email', email); diff --git a/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx b/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx index 765bc5c1..610cd527 100644 --- a/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx +++ b/apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx @@ -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')); @@ -36,7 +37,7 @@ 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 max-w-[82.6rem] flex-col items-center justify-between rounded-[2.4rem] pt-[3.2rem]', { variants: { overflow: { @@ -60,6 +61,7 @@ const MainCard = () => { const [userEmail, setUserEmail] = useState(''); const [remindTime, setRemindTime] = useState('09:00'); const [fcmToken, setFcmToken] = useState(null); + const [jobShareAgree, setJobShareAgree] = useState(true); useEffect(() => { const params = new URLSearchParams(location.search); @@ -131,6 +133,13 @@ const MainCard = () => { ); case Step.SOCIAL_LOGIN: return ; + case Step.JOB: + return ( + + ); case Step.ALARM: return ( @@ -246,6 +255,7 @@ const MainCard = () => { size="medium" className="ml-auto w-[4.8rem]" onClick={nextStep} + isDisabled={step === Step.JOB && !jobShareAgree} > 다음 diff --git a/apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx b/apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx new file mode 100644 index 00000000..440adb31 --- /dev/null +++ b/apps/client/src/pages/onBoarding/components/funnel/step/JobStep.tsx @@ -0,0 +1,140 @@ +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/constants/onboardingSteps.ts b/apps/client/src/pages/onBoarding/constants/onboardingSteps.ts index ca958fd7..79f9e958 100644 --- a/apps/client/src/pages/onBoarding/constants/onboardingSteps.ts +++ b/apps/client/src/pages/onBoarding/constants/onboardingSteps.ts @@ -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', @@ -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, diff --git a/packages/design-system/src/components/checkbox/Checkbox.stories.tsx b/packages/design-system/src/components/checkbox/Checkbox.stories.tsx new file mode 100644 index 00000000..923863b5 --- /dev/null +++ b/packages/design-system/src/components/checkbox/Checkbox.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import Checkbox from './Checkbox'; + +const meta: Meta = { + 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; + +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 }, +}; diff --git a/packages/design-system/src/components/checkbox/Checkbox.tsx b/packages/design-system/src/components/checkbox/Checkbox.tsx new file mode 100644 index 00000000..420b0264 --- /dev/null +++ b/packages/design-system/src/components/checkbox/Checkbox.tsx @@ -0,0 +1,133 @@ +import { cva } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; +import { InputHTMLAttributes, useId, useState } from 'react'; + +interface CheckboxProps + extends Omit< + InputHTMLAttributes, + 'type' | 'size' | 'checked' | 'defaultChecked' | 'onChange' + > { + isSelected?: boolean; + defaultSelected?: boolean; + onSelectedChange?: (checked: boolean) => void; + size?: 'small' | 'medium'; +} + +const checkboxBoxVariants = cva( + 'inline-flex items-center justify-center rounded-[0.4rem] transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-main400 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white-bg', + { + variants: { + size: { + small: 'h-[2rem] w-[2rem]', + medium: 'h-[2.8rem] w-[2.8rem]', + }, + selected: { + true: 'bg-main500 border-[1.5px] border-main500', + false: 'bg-gray0 border-gray100 border-[1.5px]', + }, + disabled: { + true: 'opacity-50', + false: '', + }, + }, + defaultVariants: { + size: 'medium', + selected: false, + disabled: false, + }, + } +); + +const checkIconVariants = cva('', { + variants: { + size: { + small: 'h-[0.8rem] w-[1.1rem]', + medium: 'h-[0.9rem] w-[1.2rem]', + }, + selected: { + true: 'text-white', + false: 'text-gray300', + }, + }, + defaultVariants: { + size: 'medium', + selected: false, + }, +}); + +const Checkbox = ({ + isSelected, + defaultSelected = false, + onSelectedChange, + size = 'medium', + className, + id, + disabled, + ...props +}: CheckboxProps) => { + const generatedId = useId(); + const inputId = id ?? generatedId; + + const [internalSelected, setInternalSelected] = + useState(defaultSelected); + + const isUncontrolled = isSelected === undefined; + const selected = isUncontrolled ? internalSelected : isSelected; + + const handleChange = (e: React.ChangeEvent) => { + const nextChecked = e.target.checked; + + if (isUncontrolled) setInternalSelected(nextChecked); + + onSelectedChange?.(nextChecked); + }; + + return ( +
+ + + +
+ ); +}; + +export default Checkbox; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index fc1f291b..9d22080e 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -2,6 +2,7 @@ export { default as Badge } from './badge/Badge'; export { default as Button } from './button/Button'; export { default as Card } from './card/Card'; export { default as Chip } from './chip/Chip'; +export { default as Checkbox } from './checkbox/Checkbox'; export { default as Dropdown } from './dropdown/Dropdown'; export { default as Input } from './input/Input'; export { default as DateTime } from './dateTime/DateTime';