From a02aba02d6250f0b529b5c3bbc5d037ef7b5701e Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 27 May 2026 22:25:57 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[alpha-test]=20fix=20:=20=EA=B8=B0=ED=83=80?= =?UTF-8?q?+=20=EB=B2=84=ED=8A=BC=20=EC=9E=85=EB=A0=A5=20UI=20Figma=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../onboarding-modal/steps/step-2-job.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/auth/modals/onboarding-modal/steps/step-2-job.tsx b/src/components/auth/modals/onboarding-modal/steps/step-2-job.tsx index 7626dd1c4..977b5a1e4 100644 --- a/src/components/auth/modals/onboarding-modal/steps/step-2-job.tsx +++ b/src/components/auth/modals/onboarding-modal/steps/step-2-job.tsx @@ -74,7 +74,7 @@ export function Step2Job({ data, updateData, onNext }: Step2JobProps) {

해당하는 직무를 선택해주세요.

-
+
{jobs.map((jobItem: JobResponse) => { const selected = data.job === jobItem.job; return ( @@ -99,39 +99,39 @@ export function Step2Job({ data, updateData, onNext }: Step2JobProps) { className={cn( 'rounded-full px-250 py-125 font-designer-16r transition-colors', isEtcActive - ? 'bg-rose-500 text-white' + ? 'border border-rose-500 text-rose-500' : 'border border-gray-300 text-gray-500 hover:border-rose-300', )} > 기타+ + {etcMode && ( +
+ setEtcInput(e.target.value)} + placeholder="직무를 입력해주세요" + className="flex-1 border-none bg-transparent font-designer-14r text-gray-800 outline-none placeholder:text-gray-400" + /> + + +
+ )}
- {etcMode && ( -
- setEtcInput(e.target.value)} - placeholder="직무를 입력해주세요" - className="flex-1 rounded-150 border border-rose-500 px-250 py-125 font-designer-14r text-gray-800 outline-none placeholder:text-gray-500" - /> - - -
- )}
{/* Career section */} From b4ab8dda80dfdb3f32053d4ad29bafcf79252528 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 27 May 2026 23:13:38 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[alpha-test]=20fix=20:=20chore(onboarding):?= =?UTF-8?q?=20complete=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=C2=B7API=ED=95=A8=EC=88=98=C2=B7=EB=AE=A4?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../class-onboarding/class-onboarding.ts | 42 +++++++ .../use-class-onboarding-mutation.ts | 22 ++++ src/types/api/class-onboarding.types.ts | 118 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/api/endpoints/class-onboarding/class-onboarding.ts create mode 100644 src/hooks/queries/class-onboarding/use-class-onboarding-mutation.ts create mode 100644 src/types/api/class-onboarding.types.ts diff --git a/src/api/endpoints/class-onboarding/class-onboarding.ts b/src/api/endpoints/class-onboarding/class-onboarding.ts new file mode 100644 index 000000000..11d2bf66d --- /dev/null +++ b/src/api/endpoints/class-onboarding/class-onboarding.ts @@ -0,0 +1,42 @@ +import { axiosInstance } from '@/api/client/axios'; +import type { + ClassOnboardingCompleteRequest, + ClassOnboardingStep1Request, + ClassOnboardingStep2Request, + ClassOnboardingStep3Request, + ClassOnboardingStepResponse, +} from '@/types/api/class-onboarding.types'; + +export const saveClassOnboardingStep1 = (data: ClassOnboardingStep1Request) => + axiosInstance + .post<{ content: ClassOnboardingStepResponse }>( + '/v6/class-onboarding/me/step-1', + data, + ) + .then((r) => r.data); + +export const saveClassOnboardingStep2 = (data: ClassOnboardingStep2Request) => + axiosInstance + .post<{ content: ClassOnboardingStepResponse }>( + '/v6/class-onboarding/me/step-2', + data, + ) + .then((r) => r.data); + +export const saveClassOnboardingStep3 = (data: ClassOnboardingStep3Request) => + axiosInstance + .post<{ content: ClassOnboardingStepResponse }>( + '/v6/class-onboarding/me/step-3', + data, + ) + .then((r) => r.data); + +export const saveClassOnboardingComplete = ( + data: ClassOnboardingCompleteRequest, +) => + axiosInstance + .post<{ content: ClassOnboardingStepResponse }>( + '/v6/class-onboarding/me/complete', + data, + ) + .then((r) => r.data); diff --git a/src/hooks/queries/class-onboarding/use-class-onboarding-mutation.ts b/src/hooks/queries/class-onboarding/use-class-onboarding-mutation.ts new file mode 100644 index 000000000..b78a270d0 --- /dev/null +++ b/src/hooks/queries/class-onboarding/use-class-onboarding-mutation.ts @@ -0,0 +1,22 @@ +import { useMutation } from '@tanstack/react-query'; +import { + saveClassOnboardingComplete, + saveClassOnboardingStep1, + saveClassOnboardingStep2, + saveClassOnboardingStep3, +} from '@/api/endpoints/class-onboarding/class-onboarding'; + +export const useClassOnboardingStep1Mutation = () => + useMutation({ mutationFn: saveClassOnboardingStep1 }); + +export const useClassOnboardingStep2Mutation = () => + useMutation({ mutationFn: saveClassOnboardingStep2 }); + +export const useClassOnboardingStep3Mutation = () => + useMutation({ mutationFn: saveClassOnboardingStep3 }); + +export const useClassOnboardingCompleteMutation = () => + useMutation({ + mutationFn: () => + saveClassOnboardingComplete({ confirmedOnboardingCompletion: true }), + }); diff --git a/src/types/api/class-onboarding.types.ts b/src/types/api/class-onboarding.types.ts new file mode 100644 index 000000000..8d78743cf --- /dev/null +++ b/src/types/api/class-onboarding.types.ts @@ -0,0 +1,118 @@ +export const VIBE_EXPERIENCE_OPTIONS = [ + { value: 'CURIOUS_NO_EXPERIENCE', label: '관심은 있는데 아직 안 해봤어요' }, + { + value: 'COPY_PASTE_WITH_AI', + label: 'AI한테 코드 짜달라고 해서 복사 붙여넣기해봤어요', + }, + { + value: 'TRIED_WITH_AI_BUT_GAVE_UP', + label: 'AI랑 같이 만들다가 막혀서 포기한 적이 있어요', + }, + { + value: 'BUILT_WORKING_RESULT', + label: '원하는대로 동작하는 결과물을 만들어본 적 있어요', + }, + { + value: 'DEPLOYED_AND_SHARED', + label: '배포까지 해서 실제로 쓰거나 남에게 보여준 적 있어요', + }, +] as const; + +export type VibeCodingExperienceLevel = + | (typeof VIBE_EXPERIENCE_OPTIONS)[number]['value'] + | (string & {}); + +export const IT_JOB_VALUES = [ + 'IT_NOBASE_BUSINESS_STARTUP', + 'IT_NOBASE_AUTOMATION', + 'IT_NOBASE_MY_SERVICE', + 'IT_PRACTITIONER_PM_PO_PLANNING', + 'IT_PRACTITIONER_FRONTEND', + 'IT_PRACTITIONER_BACKEND', + 'IT_PRACTITIONER_AI_ML', + 'IT_PRACTITIONER_IOS', + 'IT_PRACTITIONER_ANDROID', + 'IT_PRACTITIONER_DEVOPS', + 'IT_PRACTITIONER_DATA_ANALYSIS', + 'IT_PRACTITIONER_QA', + 'IT_PRACTITIONER_GAME_DEV', + 'IT_PRACTITIONER_DESIGN', + 'IT_PRACTITIONER_MARKETING', + 'IT_PRACTITIONER_ETC', +] as const; + +export const JOB_OPTIONS = [ + { value: 'CLASS_ONBOARDING_DESIGNER', label: '디자이너' }, + { value: 'CLASS_ONBOARDING_MARKETER', label: '마케터' }, + { + value: 'CLASS_ONBOARDING_SERVICE_PLANNER_PM_PO', + label: '서비스 기획자/PM/PO', + }, + { value: 'CLASS_ONBOARDING_STUDENT_JOB_SEEKER', label: '학생/취업준비생' }, + { value: 'CLASS_ONBOARDING_ENTREPRENEUR', label: '창업가/예비 창업자' }, + { + value: 'CLASS_ONBOARDING_NON_MAJOR_SELF_DEVELOPMENT', + label: '비전공/자기개발', + }, + { value: 'CLASS_ONBOARDING_DEVELOPER', label: '개발자' }, +] as const; + +export type ClassOnboardingJob = + | (typeof JOB_OPTIONS)[number]['value'] + | (typeof IT_JOB_VALUES)[number] + | (string & {}); + +export const CAREER_OPTIONS = [ + { value: 'JOB_SEEKER', label: '학생/취업준비생' }, + { value: 'JUNIOR', label: '주니어(0~3년)' }, + { value: 'MIDDLE', label: '미들(3~5년)' }, + { value: 'SENIOR', label: '시니어(5년~)' }, + { value: 'RETIRED', label: '퇴직자' }, +] as const; + +export type ClassOnboardingCareer = + | (typeof CAREER_OPTIONS)[number]['value'] + | (string & {}); + +export const INTEREST_OPTIONS = [ + { value: 'PORTFOLIO_SITE', label: '내 포트폴리오 사이트' }, + { value: 'SIDE_PROJECT_WEB_APP', label: '사이드 프로젝트 웹/앱' }, + { value: 'WORK_AUTOMATION_TOOL', label: '업무 자동화 도구' }, + { value: 'MONETIZATION_SERVICE', label: '수익화 서비스(창업, 부업)' }, + { value: 'OTHER', label: '기타(상세 내용 기재)' }, +] as const; + +export type ClassOnboardingInterest = + | (typeof INTEREST_OPTIONS)[number]['value'] + | (string & {}); + +export interface ClassOnboardingStep1Request { + nickname: string; + privacyConsent: boolean; + termsConsent: boolean; + marketingConsent: boolean; + vibeCodingExperienceLevel: VibeCodingExperienceLevel; +} + +export interface ClassOnboardingStep2Request { + jobs: ClassOnboardingJob[]; + jobEtcText?: string; + career: ClassOnboardingCareer; +} + +export interface ClassOnboardingStep3Request { + interests: ClassOnboardingInterest[]; + interestEtcText?: string; +} + +export interface ClassOnboardingCompleteRequest { + confirmedOnboardingCompletion: boolean; +} + +export interface ClassOnboardingStepResponse { + savedStep: string; + nextStep: string; + savedNickname: string; + savedJobs: ClassOnboardingJob[]; + savedInterests: ClassOnboardingInterest[]; +} From bbc052bea20a5cd27ad226955521e4a930a32c2f Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 27 May 2026 23:15:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[alpha-test]=20fix=20:=20feat(onboarding):?= =?UTF-8?q?=20step-4=20complete=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../onboarding-modal/onboarding-modal.tsx | 70 ++++++---- .../steps/step-1-nickname.tsx | 111 +++++++++++----- .../onboarding-modal/steps/step-2-job.tsx | 125 +++++++++++------- .../onboarding-modal/steps/step-3-goals.tsx | 97 +++++++++----- .../steps/step-4-completion.tsx | 96 ++------------ 5 files changed, 272 insertions(+), 227 deletions(-) diff --git a/src/components/auth/modals/onboarding-modal/onboarding-modal.tsx b/src/components/auth/modals/onboarding-modal/onboarding-modal.tsx index 6d5f597b5..ea53f668e 100644 --- a/src/components/auth/modals/onboarding-modal/onboarding-modal.tsx +++ b/src/components/auth/modals/onboarding-modal/onboarding-modal.tsx @@ -5,6 +5,12 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import { useOnboardingStore } from '@/stores/use-onboarding-store'; +import type { + ClassOnboardingCareer, + ClassOnboardingInterest, + ClassOnboardingJob, + VibeCodingExperienceLevel, +} from '@/types/api/class-onboarding.types'; import { Step1Nickname } from './steps/step-1-nickname'; import { Step2Job } from './steps/step-2-job'; import { Step3Goals } from './steps/step-3-goals'; @@ -16,27 +22,32 @@ interface OnboardingData { nickname: string; profileImageUrl?: string; profileImageFile?: File; - career?: string; - job?: string; - goals: string[]; - goalEtcText?: string; - termsAgreed: boolean; - privacyAgreed: boolean; - marketingAgreed: boolean; + vibeCodingExperienceLevel?: VibeCodingExperienceLevel; + jobs: ClassOnboardingJob[]; + jobEtcText?: string; + career?: ClassOnboardingCareer; + interests: ClassOnboardingInterest[]; + interestEtcText?: string; + privacyConsent: boolean; + termsConsent: boolean; + marketingConsent: boolean; } +const initialData: OnboardingData = { + nickname: '', + jobs: [], + interests: [], + privacyConsent: false, + termsConsent: false, + marketingConsent: false, +}; + export function OnboardingModal() { const { isOpen, close } = useOnboardingStore(); const [mounted, setMounted] = useState(false); const [currentStep, setCurrentStep] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); - const [data, setData] = useState({ - nickname: '', - goals: [], - termsAgreed: false, - privacyAgreed: false, - marketingAgreed: false, - }); + const [data, setData] = useState(initialData); useEffect(() => { setMounted(true); @@ -46,13 +57,7 @@ export function OnboardingModal() { useEffect(() => { if (isOpen) { setCurrentStep(1); - setData({ - nickname: '', - goals: [], - termsAgreed: false, - privacyAgreed: false, - marketingAgreed: false, - }); + setData(initialData); } }, [isOpen]); @@ -78,20 +83,29 @@ export function OnboardingModal() { data={data} updateData={updateData} onNext={handleNext} + onSubmittingChange={setIsSubmitting} /> ); case 2: return ( - + ); case 3: return ( - + ); case 4: - return ( - - ); + return ; } }; @@ -105,7 +119,7 @@ export function OnboardingModal() { /> {/* Modal panel */} -
+
{/* Header */}
{/* Back button */} @@ -116,7 +130,7 @@ export function OnboardingModal() { className="rounded-100 p-100 text-gray-500 transition-colors hover:bg-gray-100" aria-label="이전" > - + ) : (
diff --git a/src/components/auth/modals/onboarding-modal/steps/step-1-nickname.tsx b/src/components/auth/modals/onboarding-modal/steps/step-1-nickname.tsx index f4dc572d5..ddcb6c6e4 100644 --- a/src/components/auth/modals/onboarding-modal/steps/step-1-nickname.tsx +++ b/src/components/auth/modals/onboarding-modal/steps/step-1-nickname.tsx @@ -5,38 +5,42 @@ import Image from 'next/image'; import { useRef, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import { useNicknameCheckQuery } from '@/hooks/queries/auth/use-nickname-check'; -import { useCareersQuery } from '@/hooks/queries/user/use-update-user-profile-mutation'; -import type { CareerResponse } from '@/types/api/my-page.types'; +import { useClassOnboardingStep1Mutation } from '@/hooks/queries/class-onboarding/use-class-onboarding-mutation'; +import { + VIBE_EXPERIENCE_OPTIONS, + type VibeCodingExperienceLevel, +} from '@/types/api/class-onboarding.types'; interface Step1Data { nickname: string; profileImageUrl?: string; profileImageFile?: File; - career?: string; - termsAgreed: boolean; - privacyAgreed: boolean; - marketingAgreed: boolean; + vibeCodingExperienceLevel?: VibeCodingExperienceLevel; + termsConsent: boolean; + privacyConsent: boolean; + marketingConsent: boolean; } interface Step1NicknameProps { data: Step1Data; updateData: (field: keyof Step1Data, value: unknown) => void; onNext: () => void; + onSubmittingChange: (v: boolean) => void; } const CONSENTS = [ { - key: 'termsAgreed' as const, + key: 'termsConsent' as const, label: '[필수] 이용약관 동의', link: 'https://www.notion.so/gaan/29bfbb391d7980fba669f8d4de741021', }, { - key: 'privacyAgreed' as const, + key: 'privacyConsent' as const, label: '[필수] 개인정보 처리방침 동의', link: 'https://www.notion.so/gaan/29bfbb391d7980fba669f8d4de741021', }, { - key: 'marketingAgreed' as const, + key: 'marketingConsent' as const, label: '[선택] 마케팅 정보 수신 동의', link: 'https://www.notion.so/gaan/29bfbb391d7980fba669f8d4de741021', }, @@ -48,10 +52,13 @@ export function Step1Nickname({ data, updateData, onNext, + onSubmittingChange, }: Step1NicknameProps) { const fileInputRef = useRef(null); const [isCheckRequested, setIsCheckRequested] = useState(false); + const { mutate: saveStep1, isPending } = useClassOnboardingStep1Mutation(); + const isValidFormat = isValidNicknameFormat(data.nickname); const { data: nicknameCheck, isLoading: isChecking } = useNicknameCheckQuery( @@ -63,14 +70,12 @@ export function Step1Nickname({ const showAvailable = isCheckRequested && !isChecking && isAvailable === true; const showTaken = isCheckRequested && !isChecking && isAvailable === false; - const { data: careers = [] } = useCareersQuery(); - const canProceed = isValidFormat && showAvailable && - data.termsAgreed && - data.privacyAgreed && - !!data.career; + data.termsConsent && + data.privacyConsent && + !!data.vibeCodingExperienceLevel; const handleNicknameChange = (e: React.ChangeEvent) => { updateData('nickname', e.target.value); @@ -92,13 +97,42 @@ export function Step1Nickname({ updateData('profileImageFile', file); }; - const toggleConsent = (key: keyof Step1Data) => { + const toggleConsent = ( + key: keyof Pick< + Step1Data, + 'termsConsent' | 'privacyConsent' | 'marketingConsent' + >, + ) => { updateData(key, !data[key]); }; + const handleNext = () => { + if (!canProceed || isPending || !data.vibeCodingExperienceLevel) return; + onSubmittingChange(true); + saveStep1( + { + nickname: data.nickname, + privacyConsent: data.privacyConsent, + termsConsent: data.termsConsent, + marketingConsent: data.marketingConsent, + vibeCodingExperienceLevel: data.vibeCodingExperienceLevel, + }, + { + onSuccess: () => { + onSubmittingChange(false); + onNext(); + }, + onError: () => { + onSubmittingChange(false); + }, + }, + ); + }; + return (
{/* Profile image */} + {/* ⚠️ TODO: 프로필 이미지 업로드 엔드포인트 확인 후 처리 (v6 step-1에 포함 안 됨) */}
@@ -196,7 +230,7 @@ export function Step1Nickname({
toggleConsent(consent.key as keyof Step1Data)} + onClick={() => toggleConsent(consent.key)} >
- {/* Career options */} + {/* Vibe coding experience */}
- {careers.map((careerResponse: CareerResponse) => ( - - ))} +

+ 바이브 코딩 경험이 어느 정도인가요? +

+
+ {VIBE_EXPERIENCE_OPTIONS.map((option) => ( + + ))} +
{/* CTA */} ); })} @@ -98,7 +128,7 @@ export function Step2Job({ data, updateData, onNext }: Step2JobProps) { onClick={handleEtcClick} className={cn( 'rounded-full px-250 py-125 font-designer-16r transition-colors', - isEtcActive + isOtherSelected ? 'border border-rose-500 text-rose-500' : 'border border-gray-300 text-gray-500 hover:border-rose-300', )} @@ -141,32 +171,31 @@ export function Step2Job({ data, updateData, onNext }: Step2JobProps) { 현재 개발 경력 수준을 선택해주세요.

- {careers.map((careerItem: CareerResponse) => ( + {CAREER_OPTIONS.map((careerItem) => ( ))}
- {/* CTA */} - {isEtc && isEtcSelected && ( + {isOtc && isOtherSelected && (