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..f9215723f --- /dev/null +++ b/src/api/endpoints/class-onboarding/class-onboarding.ts @@ -0,0 +1,88 @@ +import { axiosInstance } from '@/api/client/axios'; +import type { + ClassOnboardingCompleteRequest, + ClassOnboardingStep1Request, + ClassOnboardingStep2Request, + ClassOnboardingStep3Request, + ClassOnboardingStepResponse, +} from '@/types/api/class-onboarding.types'; + +export const saveClassOnboardingStep1 = async ( + data: ClassOnboardingStep1Request, +): Promise => { + try { + const { data: resData } = await axiosInstance.post( + '/v6/class-onboarding/me/step-1', + data, + ); + + if (resData.statusCode !== 200) { + throw new Error('Failed to save class onboarding step 1'); + } + + return resData.content; + } catch (err) { + console.error('Error saving class onboarding step 1:', err); + throw err; + } +}; + +export const saveClassOnboardingStep2 = async ( + data: ClassOnboardingStep2Request, +): Promise => { + try { + const { data: resData } = await axiosInstance.post( + '/v6/class-onboarding/me/step-2', + data, + ); + + if (resData.statusCode !== 200) { + throw new Error('Failed to save class onboarding step 2'); + } + + return resData.content; + } catch (err) { + console.error('Error saving class onboarding step 2:', err); + throw err; + } +}; + +export const saveClassOnboardingStep3 = async ( + data: ClassOnboardingStep3Request, +): Promise => { + try { + const { data: resData } = await axiosInstance.post( + '/v6/class-onboarding/me/step-3', + data, + ); + + if (resData.statusCode !== 200) { + throw new Error('Failed to save class onboarding step 3'); + } + + return resData.content; + } catch (err) { + console.error('Error saving class onboarding step 3:', err); + throw err; + } +}; + +export const saveClassOnboardingComplete = async ( + data: ClassOnboardingCompleteRequest, +): Promise => { + try { + const { data: resData } = await axiosInstance.post( + '/v6/class-onboarding/me/complete', + data, + ); + + if (resData.statusCode !== 200) { + throw new Error('Failed to save class onboarding complete'); + } + + return resData.content; + } catch (err) { + console.error('Error saving class onboarding complete:', err); + throw err; + } +}; 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..072c7fa47 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 @@ -1,42 +1,47 @@ 'use client'; +import { useMutation } from '@tanstack/react-query'; import { Check, Loader2, RefreshCw } from 'lucide-react'; import Image from 'next/image'; import { useRef, useState } from 'react'; +import { saveClassOnboardingStep1 } from '@/api/endpoints/class-onboarding/class-onboarding'; 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 { + 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 +53,15 @@ export function Step1Nickname({ data, updateData, onNext, + onSubmittingChange, }: Step1NicknameProps) { const fileInputRef = useRef(null); const [isCheckRequested, setIsCheckRequested] = useState(false); + const { mutate: saveStep1, isPending } = useMutation({ + mutationFn: saveClassOnboardingStep1, + }); + const isValidFormat = isValidNicknameFormat(data.nickname); const { data: nicknameCheck, isLoading: isChecking } = useNicknameCheckQuery( @@ -63,14 +73,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 +100,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 +233,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,40 +132,40 @@ export function Step2Job({ data, updateData, onNext }: Step2JobProps) { onClick={handleEtcClick} className={cn( 'rounded-full px-250 py-125 font-designer-16r transition-colors', - isEtcActive - ? 'bg-rose-500 text-white' + isOtherSelected + ? '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 */} @@ -141,32 +175,31 @@ export function Step2Job({ data, updateData, onNext }: Step2JobProps) { 현재 개발 경력 수준을 선택해주세요.

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