From d24e1ef00b94cf65ae6f00508ceff6d90aade075 Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 24 Feb 2026 22:32:21 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20page.tsx=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20profile-builder=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/login/page.tsx | 7 ++----- app/onboarding/page.tsx | 7 ++----- app/page.tsx | 2 +- app/profile-builder/_components/ScreenProfileBuilder.tsx | 7 +++++++ app/profile-builder/page.tsx | 5 +++++ 5 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 app/profile-builder/_components/ScreenProfileBuilder.tsx create mode 100644 app/profile-builder/page.tsx diff --git a/app/login/page.tsx b/app/login/page.tsx index 5821d39..1749ef9 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Metadata } from "next"; import ScreenLocalLoginPage from "./_components/ScreenLocalLoginPage"; @@ -7,8 +6,6 @@ export const metadata: Metadata = { description: "COMAtching 로그인 페이지", }; -const LoginPage = () => { +export default function LoginPage() { return ; -}; - -export default LoginPage; +} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 051e8e5..38edeea 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -1,8 +1,5 @@ -import React from "react"; import ScreenOnBoarding from "./_components/ScreenOnBoarding"; -const page = () => { +export default function OnboardingPage() { return ; -}; - -export default page; +} diff --git a/app/page.tsx b/app/page.tsx index b467acc..79af3c1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import ScreenLoginPage from "@/app/_components/ScreenLoginPage"; -export default function Home() { +export default function HomePage() { return ; } diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx new file mode 100644 index 0000000..cca9f51 --- /dev/null +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +const ScreenProfileBuilder = () => { + return
ScreenProfileBuilder
; +}; + +export default ScreenProfileBuilder; diff --git a/app/profile-builder/page.tsx b/app/profile-builder/page.tsx new file mode 100644 index 0000000..b2868a5 --- /dev/null +++ b/app/profile-builder/page.tsx @@ -0,0 +1,5 @@ +import { ScreenProfileBuilder } from "./_components/ScreenProfileBuilder"; + +export default function ProfileBuilderPage() { + return ; +} From d4bde2715b8ebf8d035fb92abeb393c9d2897866 Mon Sep 17 00:00:00 2001 From: dasosann Date: Wed, 25 Feb 2026 00:33:04 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20profile-builder=20=EC=A0=9C?= =?UTF-8?q?=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 23 +- .../_components/ScreenProfileBuilder.tsx | 247 +++++++++++++++++- .../_components/Step1Basic.tsx | 106 ++++++++ .../_components/Step2Gender.tsx | 53 ++++ app/profile-builder/_components/Step3MBTI.tsx | 121 +++++++++ .../_components/Step4ContactFrequency.tsx | 64 +++++ app/profile-builder/page.tsx | 2 +- components/ui/Button.tsx | 7 +- components/ui/FormSelect.tsx | 37 ++- components/ui/ProgressStepBar.tsx | 41 +++ lib/actions/profileBuilderAction.ts | 67 +++++ lib/constants/majors.ts | 74 ++++++ lib/types/profile.ts | 58 ++++ providers/profile-provider.tsx | 74 ++++++ public/global/reverse-triangle.svg | 3 + 15 files changed, 945 insertions(+), 32 deletions(-) create mode 100644 app/profile-builder/_components/Step1Basic.tsx create mode 100644 app/profile-builder/_components/Step2Gender.tsx create mode 100644 app/profile-builder/_components/Step3MBTI.tsx create mode 100644 app/profile-builder/_components/Step4ContactFrequency.tsx create mode 100644 components/ui/ProgressStepBar.tsx create mode 100644 lib/actions/profileBuilderAction.ts create mode 100644 lib/constants/majors.ts create mode 100644 lib/types/profile.ts create mode 100644 providers/profile-provider.tsx create mode 100644 public/global/reverse-triangle.svg diff --git a/app/layout.tsx b/app/layout.tsx index 0fe0362..ef0ec29 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,8 +3,7 @@ import localFont from "next/font/local"; import "./globals.css"; import Blur from "@/components/common/Blur"; import { QueryProvider } from "@/providers/query-provider"; -import { ServiceStatusProvider } from "@/providers/service-status-provider"; -import { getInitialMaintenanceStatus } from "@/lib/status"; +import { ProfileProvider } from "@/providers/profile-provider"; const pretendard = localFont({ src: "./fonts/PretendardVariable.woff2", @@ -54,22 +53,22 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const initialMaintenanceMode = await getInitialMaintenanceStatus(); - return ( - {/* */} -
- - {children} -
- {/*
*/} + + {/* */} +
+ + {children} +
+ {/*
*/} +
diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx index cca9f51..d2aa277 100644 --- a/app/profile-builder/_components/ScreenProfileBuilder.tsx +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -1,7 +1,246 @@ -import React from "react"; +"use client"; -const ScreenProfileBuilder = () => { - return
ScreenProfileBuilder
; +import React, { useActionState, useEffect, useState } from "react"; +import Button from "@/components/ui/Button"; +import { useProfile } from "@/providers/profile-provider"; +import { + profileBuilderAction, + type ProfileBuilderState, +} from "@/lib/actions/profileBuilderAction"; +import { majorCategories, universities } from "@/lib/constants/majors"; +import ProgressStepBar from "@/components/ui/ProgressStepBar"; +import Step1Basic from "./Step1Basic"; +import Step2Gender from "./Step2Gender"; +import Step3MBTI from "./Step3MBTI"; +import Step4ContactFrequency from "./Step4ContactFrequency"; + +const initialState: ProfileBuilderState = { + success: false, + message: "", }; -export default ScreenProfileBuilder; +export const ScreenProfileBuilder = () => { + const { profile, updateProfile, isReady } = useProfile(); + const [state, formAction, isPending] = useActionState( + profileBuilderAction, + initialState, + ); + const [currentStep, setCurrentStep] = useState(1); + const [selectedUniversity, setSelectedUniversity] = useState( + profile.university || "가톨릭대학교", + ); + const [selectedDepartment, setSelectedDepartment] = useState(""); + const [selectedMajor, setSelectedMajor] = useState(""); + const [selectedGender, setSelectedGender] = useState(""); + const [selectedMBTI, setSelectedMBTI] = useState(""); + const [selectedFrequency, setSelectedFrequency] = useState(""); + + // 성공 시 Context 업데이트 및 다음 페이지로 이동 + useEffect(() => { + if (state.success && state.data) { + updateProfile(state.data); + // TODO: 다음 온보딩 페이지로 이동 + // router.push("/next-step"); + console.log("Profile updated:", state.data); + } + }, [state.success, state.data, updateProfile]); + + // localStorage 로딩 전에는 스켈레톤 UI 표시 + if (!isReady) { + return ( +
+ {/* 헤더 스켈레톤 */} +
+
+
+
+
+ + {/* 폼 스켈레톤 */} +
+ {/* 나이 */} +
+
+
+
+ + {/* 학교 */} +
+
+
+
+ + {/* 학과 / 전공 */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* 버튼 스켈레톤 */} +
+
+ ); + } + + // 연도 옵션 생성 (1997 ~ 2020) - 1997년생부터 가입 가능 + const yearOptions = Array.from({ length: 24 }, (_, i) => ({ + value: String(1997 + i), + label: `${1997 + i}년`, + })); + + // 대학 옵션 + const universityOptions = universities.map((uni) => ({ + value: uni, + label: uni, + })); + + // 선택된 학교에 따른 계열(학과) 옵션 + const getDepartmentOptions = () => { + const university = majorCategories.find( + (cat) => cat.label === selectedUniversity, + ); + + if (!university) return []; + + return university.departments.map((dept) => ({ + value: dept.label, + label: dept.label, + })); + }; + + // 선택된 계열에 따른 전공 옵션 + const getMajorOptions = () => { + const university = majorCategories.find( + (cat) => cat.label === selectedUniversity, + ); + + if (!university) return []; + + const department = university.departments.find( + (dept) => dept.label === selectedDepartment, + ); + + if (!department) return []; + + return department.majors.map((major) => ({ + value: major, + label: major, + })); + }; + + const departmentOptions = getDepartmentOptions(); + const majorOptions = getMajorOptions(); + + const handleNext = () => { + if (currentStep < 4) { + setCurrentStep(currentStep + 1); + } + }; + + const getStepTitle = () => { + switch (currentStep) { + case 1: + return "전공이 어떻게 되세요?"; + case 2: + return "성별을 알려주세요"; + case 3: + return "본인의 MBTI를 알려 주세요"; + case 4: + return "연락빈도를 알려 주세요"; + default: + return " "; + } + }; + + return ( +
{ + if (currentStep < 4) { + e.preventDefault(); + handleNext(); + } + }} + className="relative flex min-h-screen flex-col bg-white px-4 pb-32" + > + {/* 헤더 영역 */} + +
+

+ {getStepTitle()} +

+

+ 정보를 정확하게 입력했는지 확인해 주세요. +
+ 별로 오래 걸리지 않아요! +

+
+ + {/* 폼 영역 */} +
+ {/* Step 4: currentStep >= 4일 때 표시 */} + {currentStep >= 4 && ( + + )} + + {/* Step 3: currentStep >= 3일 때 표시 */} + {currentStep >= 3 && ( + + )} + + {/* Step 2: currentStep >= 2일 때 표시 */} + {currentStep >= 2 && ( + + )} + + {/* Step 1: 항상 표시 */} + { + setSelectedDepartment(value); + setSelectedMajor(""); + }} + onMajorChange={setSelectedMajor} + errors={state.errors} + /> +
+ + {/* 하단 고정 버튼 */} + + + ); +}; diff --git a/app/profile-builder/_components/Step1Basic.tsx b/app/profile-builder/_components/Step1Basic.tsx new file mode 100644 index 0000000..e58c4b8 --- /dev/null +++ b/app/profile-builder/_components/Step1Basic.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React from "react"; +import FormSelect from "@/components/ui/FormSelect"; +import { SelectOption } from "@/components/ui/FormSelect"; + +interface Step1BasicProps { + yearOptions: SelectOption[]; + universityOptions: SelectOption[]; + departmentOptions: SelectOption[]; + majorOptions: SelectOption[]; + selectedUniversity: string; + selectedDepartment: string; + selectedMajor: string; + onUniversityChange: (value: string) => void; + onDepartmentChange: (value: string) => void; + onMajorChange: (value: string) => void; + errors?: { + birthYear?: boolean; + university?: boolean; + department?: boolean; + major?: boolean; + }; +} + +export default function Step1Basic({ + yearOptions, + universityOptions, + departmentOptions, + majorOptions, + selectedUniversity, + selectedDepartment, + selectedMajor, + onUniversityChange, + onDepartmentChange, + onMajorChange, + errors, +}: Step1BasicProps) { + return ( +
+ {/* 나이 */} +
+ + +
+ + {/* 학교 */} +
+ + onUniversityChange(e.target.value)} + error={!!errors?.university} + /> +
+ + {/* 학과(계열) / 전공 */} +
+
+ + onDepartmentChange(e.target.value)} + disabled={!selectedUniversity} + error={!!errors?.department} + /> +
+
+ + onMajorChange(e.target.value)} + disabled={!selectedDepartment} + error={!!errors?.major} + /> +
+
+
+ ); +} diff --git a/app/profile-builder/_components/Step2Gender.tsx b/app/profile-builder/_components/Step2Gender.tsx new file mode 100644 index 0000000..28072cc --- /dev/null +++ b/app/profile-builder/_components/Step2Gender.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React, { useState } from "react"; + +interface Step2GenderProps { + onGenderSelect: (gender: string) => void; + defaultValue?: string; +} + +export default function Step2Gender({ + onGenderSelect, + defaultValue, +}: Step2GenderProps) { + const [selected, setSelected] = useState(defaultValue || ""); + + const handleSelect = (gender: string) => { + setSelected(gender); + onGenderSelect(gender); + }; + + return ( +
+
+ +
+ + +
+
+ +
+ ); +} diff --git a/app/profile-builder/_components/Step3MBTI.tsx b/app/profile-builder/_components/Step3MBTI.tsx new file mode 100644 index 0000000..c220c8a --- /dev/null +++ b/app/profile-builder/_components/Step3MBTI.tsx @@ -0,0 +1,121 @@ +"use client"; + +import React, { useState } from "react"; + +interface Step3MBTIProps { + onMBTISelect: (mbti: string) => void; + defaultValue?: string; +} + +interface MBTIButtonProps { + value: string; + selected: boolean; + onClick: () => void; +} + +function MBTIButton({ value, selected, onClick }: MBTIButtonProps) { + return ( + + ); +} + +export default function Step3MBTI({ + onMBTISelect, + defaultValue, +}: Step3MBTIProps) { + const [ei, setEi] = useState(defaultValue?.[0] || ""); + const [sn, setSn] = useState(defaultValue?.[1] || ""); + const [tf, setTf] = useState(defaultValue?.[2] || ""); + const [jp, setJp] = useState(defaultValue?.[3] || ""); + + const handleSelect = (category: "ei" | "sn" | "tf" | "jp", value: string) => { + if (category === "ei") setEi(value); + if (category === "sn") setSn(value); + if (category === "tf") setTf(value); + if (category === "jp") setJp(value); + + // MBTI 조합 완성 시 콜백 호출 + const newMBTI = + (category === "ei" ? value : ei) + + (category === "sn" ? value : sn) + + (category === "tf" ? value : tf) + + (category === "jp" ? value : jp); + + if (newMBTI.length === 4) { + onMBTISelect(newMBTI); + } + }; + + return ( +
+
+ + + {/* E/I */} +
+ handleSelect("ei", "E")} + /> + handleSelect("ei", "I")} + /> +
+ + {/* S/N */} +
+ handleSelect("sn", "S")} + /> + handleSelect("sn", "N")} + /> +
+ + {/* T/F */} +
+ handleSelect("tf", "T")} + /> + handleSelect("tf", "F")} + /> +
+ + {/* J/P */} +
+ handleSelect("jp", "J")} + /> + handleSelect("jp", "P")} + /> +
+
+ +
+ ); +} diff --git a/app/profile-builder/_components/Step4ContactFrequency.tsx b/app/profile-builder/_components/Step4ContactFrequency.tsx new file mode 100644 index 0000000..38c95b2 --- /dev/null +++ b/app/profile-builder/_components/Step4ContactFrequency.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React, { useState } from "react"; + +interface Step4ContactFrequencyProps { + onFrequencySelect: (frequency: string) => void; + defaultValue?: string; +} + +export default function Step4ContactFrequency({ + onFrequencySelect, + defaultValue, +}: Step4ContactFrequencyProps) { + const [selected, setSelected] = useState(defaultValue || ""); + + const handleSelect = (frequency: string) => { + setSelected(frequency); + onFrequencySelect(frequency); + }; + + return ( +
+
+ +
+ + + +
+
+ +
+ ); +} diff --git a/app/profile-builder/page.tsx b/app/profile-builder/page.tsx index b2868a5..4c273be 100644 --- a/app/profile-builder/page.tsx +++ b/app/profile-builder/page.tsx @@ -1,4 +1,4 @@ -import { ScreenProfileBuilder } from "./_components/ScreenProfileBuilder"; +import ScreenProfileBuilder from "./_components/ScreenProfileBuilder"; export default function ProfileBuilderPage() { return ; diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 49ae14a..d2eb8fe 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -60,10 +60,11 @@ export default function Button({ style={{ ...(fixed && { bottom: getBottomValue(), - left: `${sideGap}px`, - right: `${sideGap}px`, + left: "50%", + transform: "translateX(-50%)", + width: `calc(100% - ${sideGap * 2}px)`, + maxWidth: `${430 - sideGap * 2}px`, }), - // bg-button-primary일 때 border 자동 추가 ...(isPrimaryButton && !disabled && { border: "0.8px solid rgba(255, 255, 255, 0.3)", diff --git a/components/ui/FormSelect.tsx b/components/ui/FormSelect.tsx index 4755fe5..916ce65 100644 --- a/components/ui/FormSelect.tsx +++ b/components/ui/FormSelect.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React, { useState } from "react"; +import Image from "next/image"; import { cn } from "@/lib/utils"; -import { ChevronDown } from "lucide-react"; // 옵션 타입 정의 (일반적인 단일 옵션) export type SelectOption = { @@ -31,7 +31,7 @@ const SELECT_CONTAINER_STYLE = { }; const SELECT_CLASSNAME = - "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] pr-8 leading-[19px] typo-16-500 text-color-gray-900 outline-none appearance-none cursor-pointer"; + "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] pr-8 leading-[19px] typo-16-500 text-black outline-none appearance-none cursor-pointer"; // 타입 가드: 옵션 그룹인지 확인 function isOptionGroup( @@ -50,6 +50,22 @@ const FormSelect = ({ error = false, ...rest }: FormSelectProps) => { + // value prop이 없을 때만 내부 상태 사용 (uncontrolled) + const [internalValue, setInternalValue] = useState(rest.defaultValue || ""); + + const handleChange = (e: React.ChangeEvent) => { + setInternalValue(e.target.value); + if (rest.onChange) { + rest.onChange(e); + } + }; + + // controlled 컴포넌트면 value 사용, 아니면 내부 상태 사용 + const currentValue = rest.value !== undefined ? rest.value : internalValue; + + // 값이 없거나 빈 문자열인 경우 placeholder 색상 적용 + const isPlaceholder = !currentValue || currentValue === ""; + return (
{/* 오른쪽에 화살표 아이콘 (포인터 이벤트 무시하여 클릭 방해 안 함) */} -
- +
+
); diff --git a/components/ui/ProgressStepBar.tsx b/components/ui/ProgressStepBar.tsx new file mode 100644 index 0000000..1d91f80 --- /dev/null +++ b/components/ui/ProgressStepBar.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +interface ProgressStepBarProps { + currentStep: number; // 1, 2, 3 + totalSteps?: number; // 기본값 3 +} + +export default function ProgressStepBar({ + currentStep, + totalSteps = 3, +}: ProgressStepBarProps) { + return ( +
+ {Array.from({ length: totalSteps }).map((_, index) => { + const stepNumber = index + 1; + const isActive = stepNumber <= currentStep; + const isLastStep = stepNumber === totalSteps; + + return ( + + {/* 원 */} +
+ + {/* 직선 (마지막 원 뒤에는 없음) */} + {!isLastStep && ( +
+ )} + + ); + })} +
+ ); +} diff --git a/lib/actions/profileBuilderAction.ts b/lib/actions/profileBuilderAction.ts new file mode 100644 index 0000000..b7f4ce4 --- /dev/null +++ b/lib/actions/profileBuilderAction.ts @@ -0,0 +1,67 @@ +"use server"; + +import { ProfileData } from "@/lib/types/profile"; + +export type ProfileBuilderState = { + success: boolean; + message?: string; + data?: Partial; + errors?: { + birthYear?: boolean; + department?: boolean; + major?: boolean; + university?: boolean; + }; +}; + +export async function profileBuilderAction( + prevState: ProfileBuilderState, + formData: FormData, +): Promise { + const birthYear = formData.get("birthYear") as string; + const department = formData.get("department") as string; + const major = formData.get("major") as string; + const university = formData.get("university") as string; + + // 유효성 검사 + const errors: ProfileBuilderState["errors"] = {}; + + if (!birthYear) { + errors.birthYear = true; + } + + if (!university) { + errors.university = true; + } + + if (!department) { + errors.department = true; + } + + if (!major) { + errors.major = true; + } + + if (Object.keys(errors).length > 0) { + return { + success: false, + errors, + }; + } + + // birthYear를 birthDate 형식으로 변환 (YYYY-MM-DD) + // 예: 2000 -> 2000-01-01 (임시로 1월 1일로 설정, 나중에 정확한 날짜 입력받을 수 있음) + const birthDate = `${birthYear}-01-01`; + + const profileData: Partial = { + birthDate, + major, + university, + }; + + // 성공 시 데이터와 함께 반환 + return { + success: true, + data: profileData, + }; +} diff --git a/lib/constants/majors.ts b/lib/constants/majors.ts new file mode 100644 index 0000000..b9437cd --- /dev/null +++ b/lib/constants/majors.ts @@ -0,0 +1,74 @@ +export const majorCategories = [ + { + label: "가톨릭대학교", + departments: [ + { + label: "인문사회계열", + majors: [ + "인문사회계열", + "국어국문학과", + "철학과", + "국사학과", + "영어영문학부", + "중국언어문화학과", + "일어일본문화학과", + "프랑스어문화학과", + "음악과", + "성악과", + "종교학과", + "신학대학", + "사회복지학과", + "심리학과", + "사회학과", + "특수교육과", + "경영학과", + "회계학과", + "국제학부", + "법학과", + "경제학과", + "행정학과", + ], + }, + { + label: "자연공학계열", + majors: [ + "자연공학계열", + "화학과", + "수학과", + "물리학과", + "공간디자인소비자학과", + "의류학과", + "아동학과", + "식품영양학과", + "컴퓨터정보공학부", + "미디어기술콘텐츠학과", + "정보통신전자공학부", + "생명공학과", + "에너지환경공학과", + "바이오메디컬화학공학과", + "인공지능학과", + "데이터사이언스학과", + "바이오메디컬소프트웨어학과", + "바이오로직스공학부", + "AI의공학과", + ], + }, + { + label: "의약계열", + majors: ["의약계열", "약학대학", "간호대학", "의과대학"], + }, + { + label: "글로벌경영계열", + majors: ["글로벌미래경영학과", "세무회계금융학과", "IT파이낸스학과"], + }, + { + label: "자유전공", + majors: ["자유전공학부"], + }, + ], + }, +]; + +export const universities = ["가톨릭대학교"]; + +export default majorCategories; diff --git a/lib/types/profile.ts b/lib/types/profile.ts new file mode 100644 index 0000000..10cb6c0 --- /dev/null +++ b/lib/types/profile.ts @@ -0,0 +1,58 @@ +// 온보딩 프로필 데이터 타입 정의 + +export type Gender = "MALE" | "FEMALE"; +export type MBTI = + | "ISTJ" + | "ISFJ" + | "INFJ" + | "INTJ" + | "ISTP" + | "ISFP" + | "INFP" + | "INTP" + | "ESTP" + | "ESFP" + | "ENFP" + | "ENTP" + | "ESTJ" + | "ESFJ" + | "ENFJ" + | "ENTJ"; +export type SocialType = "INSTAGRAM" | "FACEBOOK" | "TWITTER" | "KAKAO"; +export type ContactFrequency = "FREQUENT" | "MODERATE" | "RARE"; + +export interface ProfileData { + // 기본 정보 + nickname?: string; + gender?: Gender; + birthDate?: string; // YYYY-MM-DD 형식 + mbti?: MBTI; + intro?: string; + profileImageUrl?: string; + + // 소셜 정보 + socialType?: SocialType; + socialAccountId?: string; + + // 학교 정보 + university?: string; + major?: string; + + // 기타 + contactFrequency?: ContactFrequency; +} + +// 백엔드 전송용 타입 (필수 필드) +export interface ProfileSubmitData { + nickname: string; + gender: Gender; + birthDate: string; + mbti: MBTI; + intro: string; + profileImageUrl: string; + socialType: SocialType; + socialAccountId: string; + university: string; + major: string; + contactFrequency: ContactFrequency; +} diff --git a/providers/profile-provider.tsx b/providers/profile-provider.tsx new file mode 100644 index 0000000..65e0526 --- /dev/null +++ b/providers/profile-provider.tsx @@ -0,0 +1,74 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect } from "react"; +import { ProfileData } from "@/lib/types/profile"; + +const STORAGE_KEY = "onboarding-profile-data"; + +interface ProfileContextType { + profile: ProfileData; + updateProfile: (data: Partial) => void; + clearProfile: () => void; + isReady: boolean; +} + +const ProfileContext = createContext(undefined); + +export function ProfileProvider({ children }: { children: React.ReactNode }) { + const [profile, setProfile] = useState({}); + const [isReady, setIsReady] = useState(false); + + // 초기 로드: localStorage에서 데이터 읽기 + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setProfile(JSON.parse(stored)); + } + } catch (error) { + console.error("Failed to load profile from localStorage:", error); + } finally { + setIsReady(true); + } + }, []); + + // 프로필 업데이트 (localStorage에도 자동 저장) + const updateProfile = (data: Partial) => { + setProfile((prev) => { + const updated = { ...prev, ...data }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch (error) { + console.error("Failed to save profile to localStorage:", error); + } + return updated; + }); + }; + + // 프로필 초기화 (온보딩 완료 후 사용) + const clearProfile = () => { + setProfile({}); + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.error("Failed to clear profile from localStorage:", error); + } + }; + + return ( + + {children} + + ); +} + +// useProfile 훅 +export function useProfile() { + const context = useContext(ProfileContext); + if (context === undefined) { + throw new Error("useProfile must be used within ProfileProvider"); + } + return context; +} diff --git a/public/global/reverse-triangle.svg b/public/global/reverse-triangle.svg new file mode 100644 index 0000000..cd05a17 --- /dev/null +++ b/public/global/reverse-triangle.svg @@ -0,0 +1,3 @@ + + + From e34076f6330cde72ddb5ea0481197a0bd7b71fde Mon Sep 17 00:00:00 2001 From: dasosann Date: Wed, 25 Feb 2026 00:34:36 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=A3=BC=EC=84=9D=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/layout.tsx b/app/layout.tsx index ef0ec29..9de385d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,8 @@ import "./globals.css"; import Blur from "@/components/common/Blur"; import { QueryProvider } from "@/providers/query-provider"; import { ProfileProvider } from "@/providers/profile-provider"; +// import { ServiceStatusProvider } from "@/providers/service-status-provider"; +// import { getInitialMaintenanceStatus } from "@/lib/status"; const pretendard = localFont({ src: "./fonts/PretendardVariable.woff2", @@ -53,6 +55,8 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + // const initialMaintenanceMode = await getInitialMaintenanceStatus(); + return ( Date: Wed, 25 Feb 2026 14:36:41 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/ScreenProfileBuilder.tsx | 130 ++++-------------- .../_components/Step4ContactFrequency.tsx | 6 +- app/profile-builder/_lib/options.ts | 69 ++++++++++ app/profile-builder/_lib/step.ts | 14 ++ app/profile-builder/page.tsx | 2 +- lib/actions/profileBuilderAction.ts | 64 ++++++++- lib/types/profile.ts | 2 +- 7 files changed, 174 insertions(+), 113 deletions(-) create mode 100644 app/profile-builder/_lib/options.ts create mode 100644 app/profile-builder/_lib/step.ts diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx index d2aa277..df523eb 100644 --- a/app/profile-builder/_components/ScreenProfileBuilder.tsx +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useActionState, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import Button from "@/components/ui/Button"; import { useProfile } from "@/providers/profile-provider"; import { @@ -8,6 +9,13 @@ import { type ProfileBuilderState, } from "@/lib/actions/profileBuilderAction"; import { majorCategories, universities } from "@/lib/constants/majors"; +import { + getDepartmentOptions, + getMajorOptions, + getUniversityOptions, + getYearOptions, +} from "../_lib/options"; +import { getStepTitle } from "../_lib/step"; import ProgressStepBar from "@/components/ui/ProgressStepBar"; import Step1Basic from "./Step1Basic"; import Step2Gender from "./Step2Gender"; @@ -20,6 +28,7 @@ const initialState: ProfileBuilderState = { }; export const ScreenProfileBuilder = () => { + const router = useRouter(); const { profile, updateProfile, isReady } = useProfile(); const [state, formAction, isPending] = useActionState( profileBuilderAction, @@ -40,103 +49,25 @@ export const ScreenProfileBuilder = () => { if (state.success && state.data) { updateProfile(state.data); // TODO: 다음 온보딩 페이지로 이동 - // router.push("/next-step"); + router.push("/hobby-select"); console.log("Profile updated:", state.data); } }, [state.success, state.data, updateProfile]); // localStorage 로딩 전에는 스켈레톤 UI 표시 - if (!isReady) { - return ( -
- {/* 헤더 스켈레톤 */} -
-
-
-
-
- - {/* 폼 스켈레톤 */} -
- {/* 나이 */} -
-
-
-
- - {/* 학교 */} -
-
-
-
- - {/* 학과 / 전공 */} -
-
-
-
-
-
-
-
-
-
-
- - {/* 버튼 스켈레톤 */} -
-
- ); - } - - // 연도 옵션 생성 (1997 ~ 2020) - 1997년생부터 가입 가능 - const yearOptions = Array.from({ length: 24 }, (_, i) => ({ - value: String(1997 + i), - label: `${1997 + i}년`, - })); - - // 대학 옵션 - const universityOptions = universities.map((uni) => ({ - value: uni, - label: uni, - })); - - // 선택된 학교에 따른 계열(학과) 옵션 - const getDepartmentOptions = () => { - const university = majorCategories.find( - (cat) => cat.label === selectedUniversity, - ); - - if (!university) return []; - - return university.departments.map((dept) => ({ - value: dept.label, - label: dept.label, - })); - }; + // (스켈레톤 제거됨) - // 선택된 계열에 따른 전공 옵션 - const getMajorOptions = () => { - const university = majorCategories.find( - (cat) => cat.label === selectedUniversity, - ); - - if (!university) return []; - - const department = university.departments.find( - (dept) => dept.label === selectedDepartment, - ); - - if (!department) return []; - - return department.majors.map((major) => ({ - value: major, - label: major, - })); - }; - - const departmentOptions = getDepartmentOptions(); - const majorOptions = getMajorOptions(); + const yearOptions = getYearOptions(); + const universityOptions = getUniversityOptions(universities); + const departmentOptions = getDepartmentOptions( + selectedUniversity, + majorCategories, + ); + const majorOptions = getMajorOptions( + selectedUniversity, + selectedDepartment, + majorCategories, + ); const handleNext = () => { if (currentStep < 4) { @@ -144,21 +75,6 @@ export const ScreenProfileBuilder = () => { } }; - const getStepTitle = () => { - switch (currentStep) { - case 1: - return "전공이 어떻게 되세요?"; - case 2: - return "성별을 알려주세요"; - case 3: - return "본인의 MBTI를 알려 주세요"; - case 4: - return "연락빈도를 알려 주세요"; - default: - return " "; - } - }; - return (
{

- {getStepTitle()} + {getStepTitle(currentStep)}

정보를 정확하게 입력했는지 확인해 주세요. diff --git a/app/profile-builder/_components/Step4ContactFrequency.tsx b/app/profile-builder/_components/Step4ContactFrequency.tsx index 38c95b2..353f0cf 100644 --- a/app/profile-builder/_components/Step4ContactFrequency.tsx +++ b/app/profile-builder/_components/Step4ContactFrequency.tsx @@ -47,14 +47,14 @@ export default function Step4ContactFrequency({

diff --git a/app/profile-builder/_lib/options.ts b/app/profile-builder/_lib/options.ts new file mode 100644 index 0000000..b2cc560 --- /dev/null +++ b/app/profile-builder/_lib/options.ts @@ -0,0 +1,69 @@ +type Option = { + value: string; + label: string; +}; + +type MajorCategory = { + label: string; + departments: { + label: string; + majors: string[]; + }[]; +}; + +export const getYearOptions = (): Option[] => + Array.from({ length: 24 }, (_, index) => ({ + value: String(1997 + index), + label: `${1997 + index}년`, + })); + +export const getUniversityOptions = (universities: string[]): Option[] => + universities.map((university) => ({ + value: university, + label: university, + })); + +export const getDepartmentOptions = ( + selectedUniversity: string, + categories: MajorCategory[], +): Option[] => { + const university = categories.find( + (category) => category.label === selectedUniversity, + ); + + if (!university) { + return []; + } + + return university.departments.map((department) => ({ + value: department.label, + label: department.label, + })); +}; + +export const getMajorOptions = ( + selectedUniversity: string, + selectedDepartment: string, + categories: MajorCategory[], +): Option[] => { + const university = categories.find( + (category) => category.label === selectedUniversity, + ); + + if (!university) { + return []; + } + + const department = university.departments.find( + (item) => item.label === selectedDepartment, + ); + + if (!department) { + return []; + } + + return department.majors.map((major) => ({ + value: major, + label: major, + })); +}; diff --git a/app/profile-builder/_lib/step.ts b/app/profile-builder/_lib/step.ts new file mode 100644 index 0000000..2d2df0e --- /dev/null +++ b/app/profile-builder/_lib/step.ts @@ -0,0 +1,14 @@ +export const getStepTitle = (step: number): string => { + switch (step) { + case 1: + return "전공이 어떻게 되세요?"; + case 2: + return "성별을 알려주세요"; + case 3: + return "본인의 MBTI를 알려 주세요"; + case 4: + return "연락빈도를 알려 주세요"; + default: + return ""; + } +}; diff --git a/app/profile-builder/page.tsx b/app/profile-builder/page.tsx index 4c273be..b2868a5 100644 --- a/app/profile-builder/page.tsx +++ b/app/profile-builder/page.tsx @@ -1,4 +1,4 @@ -import ScreenProfileBuilder from "./_components/ScreenProfileBuilder"; +import { ScreenProfileBuilder } from "./_components/ScreenProfileBuilder"; export default function ProfileBuilderPage() { return ; diff --git a/lib/actions/profileBuilderAction.ts b/lib/actions/profileBuilderAction.ts index b7f4ce4..189fc74 100644 --- a/lib/actions/profileBuilderAction.ts +++ b/lib/actions/profileBuilderAction.ts @@ -1,6 +1,11 @@ "use server"; -import { ProfileData } from "@/lib/types/profile"; +import { + ProfileData, + ContactFrequency, + Gender, + MBTI, +} from "@/lib/types/profile"; export type ProfileBuilderState = { success: boolean; @@ -11,17 +16,59 @@ export type ProfileBuilderState = { department?: boolean; major?: boolean; university?: boolean; + gender?: boolean; + mbti?: boolean; + contactFrequency?: boolean; }; }; +const genderMap: Record = { + 남자: "MALE", + 여자: "FEMALE", +}; + +const contactFrequencyMap: Record = { + 자주: "FREQUENT", + 보통: "NORMAL", + 적음: "RARE", +}; + +const mbtiSet = new Set([ + "ISTJ", + "ISFJ", + "INFJ", + "INTJ", + "ISTP", + "ISFP", + "INFP", + "INTP", + "ESTP", + "ESFP", + "ENFP", + "ENTP", + "ESTJ", + "ESFJ", + "ENFJ", + "ENTJ", +]); + export async function profileBuilderAction( prevState: ProfileBuilderState, formData: FormData, ): Promise { + void prevState; + const birthYear = formData.get("birthYear") as string; const department = formData.get("department") as string; const major = formData.get("major") as string; const university = formData.get("university") as string; + const genderRaw = formData.get("gender") as string; + const mbtiRaw = (formData.get("mbti") as string)?.toUpperCase(); + const contactFrequencyRaw = formData.get("contactFrequency") as string; + + const gender = genderMap[genderRaw]; + const contactFrequency = contactFrequencyMap[contactFrequencyRaw]; + const mbti = mbtiSet.has(mbtiRaw as MBTI) ? (mbtiRaw as MBTI) : undefined; // 유효성 검사 const errors: ProfileBuilderState["errors"] = {}; @@ -42,6 +89,18 @@ export async function profileBuilderAction( errors.major = true; } + if (!gender) { + errors.gender = true; + } + + if (!mbti) { + errors.mbti = true; + } + + if (!contactFrequency) { + errors.contactFrequency = true; + } + if (Object.keys(errors).length > 0) { return { success: false, @@ -55,8 +114,11 @@ export async function profileBuilderAction( const profileData: Partial = { birthDate, + gender, + mbti, major, university, + contactFrequency, }; // 성공 시 데이터와 함께 반환 diff --git a/lib/types/profile.ts b/lib/types/profile.ts index 10cb6c0..616a290 100644 --- a/lib/types/profile.ts +++ b/lib/types/profile.ts @@ -19,7 +19,7 @@ export type MBTI = | "ENFJ" | "ENTJ"; export type SocialType = "INSTAGRAM" | "FACEBOOK" | "TWITTER" | "KAKAO"; -export type ContactFrequency = "FREQUENT" | "MODERATE" | "RARE"; +export type ContactFrequency = "FREQUENT" | "NORMAL" | "RARE"; export interface ProfileData { // 기본 정보 From 0756296bd589def8dd88bb2dadacdab6c8ee9c11 Mon Sep 17 00:00:00 2001 From: dasosann Date: Wed, 25 Feb 2026 16:40:57 +0900 Subject: [PATCH 05/10] feat: implement multi-step profile builder screen with basic information input. --- app/profile-builder/_components/ScreenProfileBuilder.tsx | 6 ++---- app/profile-builder/_components/Step1Basic.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx index df523eb..3b441dc 100644 --- a/app/profile-builder/_components/ScreenProfileBuilder.tsx +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -35,9 +35,7 @@ export const ScreenProfileBuilder = () => { initialState, ); const [currentStep, setCurrentStep] = useState(1); - const [selectedUniversity, setSelectedUniversity] = useState( - profile.university || "가톨릭대학교", - ); + const [selectedUniversity, setSelectedUniversity] = useState(""); const [selectedDepartment, setSelectedDepartment] = useState(""); const [selectedMajor, setSelectedMajor] = useState(""); const [selectedGender, setSelectedGender] = useState(""); @@ -84,7 +82,7 @@ export const ScreenProfileBuilder = () => { handleNext(); } }} - className="relative flex min-h-screen flex-col bg-white px-4 pb-32" + className="relative flex min-h-screen flex-col px-4 pb-32" > {/* 헤더 영역 */} diff --git a/app/profile-builder/_components/Step1Basic.tsx b/app/profile-builder/_components/Step1Basic.tsx index e58c4b8..3d20454 100644 --- a/app/profile-builder/_components/Step1Basic.tsx +++ b/app/profile-builder/_components/Step1Basic.tsx @@ -62,7 +62,7 @@ export default function Step1Basic({ name="university" options={universityOptions} placeholder="선택" - defaultValue="가톨릭대학교" + defaultValue="" onChange={(e) => onUniversityChange(e.target.value)} error={!!errors?.university} /> From 8775b96f13ab6628df7fe8b2d467c670a85350dc Mon Sep 17 00:00:00 2001 From: dasosann Date: Thu, 26 Feb 2026 16:37:47 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/ScreenProfileBuilder.tsx | 11 +- .../_components/Step1Basic.tsx | 12 +- components/ui/FormSelect.tsx | 193 ++++++++++++++---- 3 files changed, 173 insertions(+), 43 deletions(-) diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx index 3b441dc..8458fa0 100644 --- a/app/profile-builder/_components/ScreenProfileBuilder.tsx +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -35,6 +35,7 @@ export const ScreenProfileBuilder = () => { initialState, ); const [currentStep, setCurrentStep] = useState(1); + const [selectedBirthYear, setSelectedBirthYear] = useState(""); const [selectedUniversity, setSelectedUniversity] = useState(""); const [selectedDepartment, setSelectedDepartment] = useState(""); const [selectedMajor, setSelectedMajor] = useState(""); @@ -42,6 +43,12 @@ export const ScreenProfileBuilder = () => { const [selectedMBTI, setSelectedMBTI] = useState(""); const [selectedFrequency, setSelectedFrequency] = useState(""); + console.log( + `[ScreenProfileBuilder Render] Step: ${currentStep}, ` + + `Birth: ${selectedBirthYear}, Univ: ${selectedUniversity}, ` + + `Dept: ${selectedDepartment}, Major: ${selectedMajor}`, + ); + // 성공 시 Context 업데이트 및 다음 페이지로 이동 useEffect(() => { if (state.success && state.data) { @@ -75,7 +82,7 @@ export const ScreenProfileBuilder = () => { return ( { if (currentStep < 4) { e.preventDefault(); @@ -129,9 +136,11 @@ export const ScreenProfileBuilder = () => { universityOptions={universityOptions} departmentOptions={departmentOptions} majorOptions={majorOptions} + selectedBirthYear={selectedBirthYear} selectedUniversity={selectedUniversity} selectedDepartment={selectedDepartment} selectedMajor={selectedMajor} + onBirthYearChange={setSelectedBirthYear} onUniversityChange={setSelectedUniversity} onDepartmentChange={(value) => { setSelectedDepartment(value); diff --git a/app/profile-builder/_components/Step1Basic.tsx b/app/profile-builder/_components/Step1Basic.tsx index 3d20454..ceae041 100644 --- a/app/profile-builder/_components/Step1Basic.tsx +++ b/app/profile-builder/_components/Step1Basic.tsx @@ -9,9 +9,11 @@ interface Step1BasicProps { universityOptions: SelectOption[]; departmentOptions: SelectOption[]; majorOptions: SelectOption[]; + selectedBirthYear: string; selectedUniversity: string; selectedDepartment: string; selectedMajor: string; + onBirthYearChange: (value: string) => void; onUniversityChange: (value: string) => void; onDepartmentChange: (value: string) => void; onMajorChange: (value: string) => void; @@ -28,9 +30,11 @@ export default function Step1Basic({ universityOptions, departmentOptions, majorOptions, + selectedBirthYear, selectedUniversity, selectedDepartment, selectedMajor, + onBirthYearChange, onUniversityChange, onDepartmentChange, onMajorChange, @@ -44,10 +48,13 @@ export default function Step1Basic({ 나이 onBirthYearChange(e.target.value)} error={!!errors?.birthYear} />
@@ -58,11 +65,12 @@ export default function Step1Basic({ 학교 onUniversityChange(e.target.value)} error={!!errors?.university} /> @@ -75,6 +83,7 @@ export default function Step1Basic({ 학과 , - "id" | "name" + React.ButtonHTMLAttributes, + "id" | "name" | "value" | "onChange" > { id: string; name: string; options: (SelectOption | SelectOptionGroup)[]; placeholder?: string; error?: boolean; + value?: string; + defaultValue?: string; + onChange?: (e: React.ChangeEvent) => void; + disabled?: boolean; } const SELECT_CONTAINER_STYLE = { @@ -31,7 +35,7 @@ const SELECT_CONTAINER_STYLE = { }; const SELECT_CLASSNAME = - "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] pr-8 leading-[19px] typo-16-500 text-black outline-none appearance-none cursor-pointer"; + "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] pr-8 leading-[19px] typo-16-500 text-black outline-none appearance-none cursor-pointer flex items-center"; // 타입 가드: 옵션 그룹인지 확인 function isOptionGroup( @@ -48,67 +52,174 @@ const FormSelect = ({ className = "", style = {}, error = false, + value, + defaultValue, + onChange, + disabled = false, ...rest }: FormSelectProps) => { - // value prop이 없을 때만 내부 상태 사용 (uncontrolled) - const [internalValue, setInternalValue] = useState(rest.defaultValue || ""); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); - const handleChange = (e: React.ChangeEvent) => { - setInternalValue(e.target.value); - if (rest.onChange) { - rest.onChange(e); + // uncontrolled 상태 관리를 위한 내부 상태 + const [internalValue, setInternalValue] = useState(defaultValue || ""); + + // 제어/비제어 값 결정 + const isControlled = value !== undefined; + const currentValue = isControlled ? value : internalValue; + + const handleSelect = (selectedValue: string) => { + // 닫기 + setIsOpen(false); + + // 내부 상태 업데이트 + setInternalValue(selectedValue); + + // onChange(faked event) + if (onChange) { + // Create a fake event object to match the signature of a typical input onChange + const event = { + target: { name, value: selectedValue }, + } as React.ChangeEvent; + onChange(event); } }; - // controlled 컴포넌트면 value 사용, 아니면 내부 상태 사용 - const currentValue = rest.value !== undefined ? rest.value : internalValue; + // 외부 영역 클릭 시 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); // 값이 없거나 빈 문자열인 경우 placeholder 색상 적용 const isPlaceholder = !currentValue || currentValue === ""; + // 현재 선택된 라벨 찾기 (디스플레이용) + const currentLabel = React.useMemo(() => { + if (!currentValue) return placeholder || ""; + for (const option of options) { + if (isOptionGroup(option)) { + const found = option.options.find((o) => o.value === currentValue); + if (found) return found.label; + } else { + if (option.value === currentValue) return option.label; + } + } + return currentValue; + }, [currentValue, options, placeholder]); + return ( -
- + + {/* 오른쪽에 화살표 아이콘 (포인터 이벤트 무시하여 클릭 방해 안 함) */}
- +
+ + {/* 드롭다운 메뉴 영역 */} + {isOpen && !disabled && ( +
+ {options.map((item, index) => { + if (isOptionGroup(item)) { + return ( +
+
+ {item.label} +
+
+ {item.options.map((opt) => ( + + ))} +
+
+ ); + } + + return ( + + ); + })} + {options.length === 0 && ( +
+ 선택할 수 있는 항목이 없습니다. +
+ )} +
+ )}
); }; From 834a9395fbb78d57f8a14024a211382cb724de0d Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 27 Feb 2026 00:05:39 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20hobby=20select=20=EB=B0=8F=20css?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 19 +++ .../_components/ScreenHobbySelect.tsx | 105 +++++++++++++ app/hobby-select/page.tsx | 5 + .../_components/ProfileButton.tsx | 29 ++++ .../_components/ScreenProfileBuilder.tsx | 148 +++++++++--------- .../_components/Step1Basic.tsx | 11 -- .../_components/Step2Gender.tsx | 27 ++-- app/profile-builder/_components/Step3MBTI.tsx | 130 +++++++-------- .../_components/Step4ContactFrequency.tsx | 36 ++--- lib/constants/hobbies.ts | 104 ++++++++++++ lib/types/profile.ts | 3 + 11 files changed, 412 insertions(+), 205 deletions(-) create mode 100644 app/hobby-select/_components/ScreenHobbySelect.tsx create mode 100644 app/hobby-select/page.tsx create mode 100644 app/profile-builder/_components/ProfileButton.tsx create mode 100644 lib/constants/hobbies.ts diff --git a/app/globals.css b/app/globals.css index fc48621..eef22fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -124,3 +124,22 @@ @apply bg-background text-foreground; } } + +/* ============================ + Shared bg-pink-conic utility + ============================ */ + +.bg-pink-conic { + background: + conic-gradient( + from -36.07deg at 108.5px -49.1px, + #e83abc -66.6deg, + rgba(255, 119, 94, 0.1) 0.04deg, + rgba(255, 77, 97, 0.6) 169.2deg, + #e83abc 192.6deg, + rgba(255, 98, 95, 0.344627) 208.31deg, + #e83abc 293.4deg, + rgba(255, 119, 94, 0.1) 360.04deg + ), + rgba(255, 255, 255, 0.2); +} diff --git a/app/hobby-select/_components/ScreenHobbySelect.tsx b/app/hobby-select/_components/ScreenHobbySelect.tsx new file mode 100644 index 0000000..03c4061 --- /dev/null +++ b/app/hobby-select/_components/ScreenHobbySelect.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { BackButton } from "@/components/ui/BackButton"; +import Button from "@/components/ui/Button"; +import { useProfile } from "@/providers/profile-provider"; +import ProgressStepBar from "@/components/ui/ProgressStepBar"; +import Blur from "@/components/common/Blur"; + +import { HOBBIES, type HobbyCategory } from "@/lib/constants/hobbies"; + +const ScreenHobbySelect = () => { + const router = useRouter(); + const { profile, updateProfile } = useProfile(); + const [selected, setSelected] = useState(profile.hobbies || []); + + const toggleHobby = (hobby: string) => { + setSelected((prev) => { + const isAlreadySelected = prev.includes(hobby); + + if (!isAlreadySelected && prev.length >= 10) { + alert("최대 10개까지 선택할 수 있어요."); + return prev; + } + + return isAlreadySelected + ? prev.filter((h) => h !== hobby) + : [...prev, hobby]; + }); + }; + + const handleComplete = () => { + // Context 업데이트 + updateProfile({ + ...profile, + hobbies: selected, + }); + + // 다음 페이지로 이동 (회원가입/추가 정보 입력 등) + router.push("/extra-info"); + }; + + return ( +
+ + +
+ + +
+ +
+

관심사를 알려주세요.

+

+ 요즘 관심있는 것들을 3개 이상 선택해주세요. +
+ 최대 10개까지 선택할 수 있어요. +

+
+ +
+ {(Object.keys(HOBBIES) as HobbyCategory[]).map((category) => ( +
+

{category}

+
+ {HOBBIES[category].map((hobby) => { + const isSelected = selected.includes(hobby); + return ( + + ); + })} +
+
+ ))} +
+ + +
+ ); +}; + +export default ScreenHobbySelect; diff --git a/app/hobby-select/page.tsx b/app/hobby-select/page.tsx new file mode 100644 index 0000000..7a20bcb --- /dev/null +++ b/app/hobby-select/page.tsx @@ -0,0 +1,5 @@ +import ScreenHobbySelect from "./_components/ScreenHobbySelect"; + +export default function HobbySelectPage() { + return ; +} diff --git a/app/profile-builder/_components/ProfileButton.tsx b/app/profile-builder/_components/ProfileButton.tsx new file mode 100644 index 0000000..18cbc1a --- /dev/null +++ b/app/profile-builder/_components/ProfileButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; + +interface ProfileButtonProps { + children: React.ReactNode; + selected?: boolean; + onClick?: () => void; +} + +export default function ProfileButton({ + children, + selected = false, + onClick, +}: ProfileButtonProps) { + const baseClass = + "typo-20-700 flex-1 rounded-full h-12 flex items-center justify-center transition-colors"; + const activeClass = "bg-pink-conic border border-pink-700 text-pink-700"; + const inactiveClass = "bg-[#FFFFFF4D] text-gray-300"; + return ( + + ); +} diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx index 8458fa0..bc192d6 100644 --- a/app/profile-builder/_components/ScreenProfileBuilder.tsx +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -1,13 +1,9 @@ "use client"; -import React, { useActionState, useEffect, useState } from "react"; +import React, { useState } from "react"; import { useRouter } from "next/navigation"; import Button from "@/components/ui/Button"; import { useProfile } from "@/providers/profile-provider"; -import { - profileBuilderAction, - type ProfileBuilderState, -} from "@/lib/actions/profileBuilderAction"; import { majorCategories, universities } from "@/lib/constants/majors"; import { getDepartmentOptions, @@ -21,19 +17,28 @@ import Step1Basic from "./Step1Basic"; import Step2Gender from "./Step2Gender"; import Step3MBTI from "./Step3MBTI"; import Step4ContactFrequency from "./Step4ContactFrequency"; +import { + ContactFrequency, + Gender, + MBTI, + ProfileData, +} from "@/lib/types/profile"; + +const genderMap: Record = { + 남자: "MALE", + 여자: "FEMALE", +}; -const initialState: ProfileBuilderState = { - success: false, - message: "", +const contactFrequencyMap: Record = { + 자주: "FREQUENT", + 보통: "NORMAL", + 적음: "RARE", }; export const ScreenProfileBuilder = () => { const router = useRouter(); const { profile, updateProfile, isReady } = useProfile(); - const [state, formAction, isPending] = useActionState( - profileBuilderAction, - initialState, - ); + const [currentStep, setCurrentStep] = useState(1); const [selectedBirthYear, setSelectedBirthYear] = useState(""); const [selectedUniversity, setSelectedUniversity] = useState(""); @@ -43,25 +48,6 @@ export const ScreenProfileBuilder = () => { const [selectedMBTI, setSelectedMBTI] = useState(""); const [selectedFrequency, setSelectedFrequency] = useState(""); - console.log( - `[ScreenProfileBuilder Render] Step: ${currentStep}, ` + - `Birth: ${selectedBirthYear}, Univ: ${selectedUniversity}, ` + - `Dept: ${selectedDepartment}, Major: ${selectedMajor}`, - ); - - // 성공 시 Context 업데이트 및 다음 페이지로 이동 - useEffect(() => { - if (state.success && state.data) { - updateProfile(state.data); - // TODO: 다음 온보딩 페이지로 이동 - router.push("/hobby-select"); - console.log("Profile updated:", state.data); - } - }, [state.success, state.data, updateProfile]); - - // localStorage 로딩 전에는 스켈레톤 UI 표시 - // (스켈레톤 제거됨) - const yearOptions = getYearOptions(); const universityOptions = getUniversityOptions(universities); const departmentOptions = getDepartmentOptions( @@ -80,17 +66,27 @@ export const ScreenProfileBuilder = () => { } }; + const handleComplete = () => { + // Context 업데이트용 데이터 변환 + const profileData: Partial = { + birthDate: selectedBirthYear ? `${selectedBirthYear}-01-01` : undefined, + university: selectedUniversity, + department: selectedDepartment, + major: selectedMajor, + gender: genderMap[selectedGender], + mbti: selectedMBTI as MBTI, + contactFrequency: contactFrequencyMap[selectedFrequency], + }; + + // Context 업데이트 + updateProfile(profileData); + + // 다음 페이지로 이동 + router.push("/hobby-select"); + }; + return ( - { - if (currentStep < 4) { - e.preventDefault(); - handleNext(); - } - }} - className="relative flex min-h-screen flex-col px-4 pb-32" - > +
{/* 헤더 영역 */}
@@ -106,64 +102,60 @@ export const ScreenProfileBuilder = () => { {/* 폼 영역 */}
- {/* Step 4: currentStep >= 4일 때 표시 */} - {currentStep >= 4 && ( - { + setSelectedDepartment(value); + setSelectedMajor(""); + }} + onMajorChange={setSelectedMajor} + /> + )} + + {currentStep === 2 && ( + )} - {/* Step 3: currentStep >= 3일 때 표시 */} - {currentStep >= 3 && ( + {currentStep === 3 && ( )} - {/* Step 2: currentStep >= 2일 때 표시 */} - {currentStep >= 2 && ( - )} - - {/* Step 1: 항상 표시 */} - { - setSelectedDepartment(value); - setSelectedMajor(""); - }} - onMajorChange={setSelectedMajor} - errors={state.errors} - />
{/* 하단 고정 버튼 */} - +
); }; diff --git a/app/profile-builder/_components/Step1Basic.tsx b/app/profile-builder/_components/Step1Basic.tsx index ceae041..d53196d 100644 --- a/app/profile-builder/_components/Step1Basic.tsx +++ b/app/profile-builder/_components/Step1Basic.tsx @@ -17,12 +17,6 @@ interface Step1BasicProps { onUniversityChange: (value: string) => void; onDepartmentChange: (value: string) => void; onMajorChange: (value: string) => void; - errors?: { - birthYear?: boolean; - university?: boolean; - department?: boolean; - major?: boolean; - }; } export default function Step1Basic({ @@ -38,7 +32,6 @@ export default function Step1Basic({ onUniversityChange, onDepartmentChange, onMajorChange, - errors, }: Step1BasicProps) { return (
@@ -55,7 +48,6 @@ export default function Step1Basic({ placeholder="태어난 년도" value={selectedBirthYear} onChange={(e) => onBirthYearChange(e.target.value)} - error={!!errors?.birthYear} />
@@ -72,7 +64,6 @@ export default function Step1Basic({ placeholder="선택" value={selectedUniversity} onChange={(e) => onUniversityChange(e.target.value)} - error={!!errors?.university} />
@@ -91,7 +82,6 @@ export default function Step1Basic({ value={selectedDepartment} onChange={(e) => onDepartmentChange(e.target.value)} disabled={!selectedUniversity} - error={!!errors?.department} />
@@ -107,7 +97,6 @@ export default function Step1Basic({ value={selectedMajor} onChange={(e) => onMajorChange(e.target.value)} disabled={!selectedDepartment} - error={!!errors?.major} />
diff --git a/app/profile-builder/_components/Step2Gender.tsx b/app/profile-builder/_components/Step2Gender.tsx index 28072cc..dabe227 100644 --- a/app/profile-builder/_components/Step2Gender.tsx +++ b/app/profile-builder/_components/Step2Gender.tsx @@ -1,6 +1,9 @@ "use client"; import React, { useState } from "react"; +import ProfileButton from "./ProfileButton"; + +// GenderButton 컴포넌트 제거 interface Step2GenderProps { onGenderSelect: (gender: string) => void; @@ -22,29 +25,19 @@ export default function Step2Gender({
-
- - +
diff --git a/app/profile-builder/_components/Step3MBTI.tsx b/app/profile-builder/_components/Step3MBTI.tsx index c220c8a..c245a93 100644 --- a/app/profile-builder/_components/Step3MBTI.tsx +++ b/app/profile-builder/_components/Step3MBTI.tsx @@ -1,32 +1,13 @@ "use client"; import React, { useState } from "react"; +import ProfileButton from "./ProfileButton"; interface Step3MBTIProps { onMBTISelect: (mbti: string) => void; defaultValue?: string; } -interface MBTIButtonProps { - value: string; - selected: boolean; - onClick: () => void; -} - -function MBTIButton({ value, selected, onClick }: MBTIButtonProps) { - return ( - - ); -} - export default function Step3MBTI({ onMBTISelect, defaultValue, @@ -42,7 +23,6 @@ export default function Step3MBTI({ if (category === "tf") setTf(value); if (category === "jp") setJp(value); - // MBTI 조합 완성 시 콜백 호출 const newMBTI = (category === "ei" ? value : ei) + (category === "sn" ? value : sn) + @@ -59,60 +39,62 @@ export default function Step3MBTI({
- {/* E/I */} -
- handleSelect("ei", "E")} - /> - handleSelect("ei", "I")} - /> -
- - {/* S/N */} -
- handleSelect("sn", "S")} - /> - handleSelect("sn", "N")} - /> -
- - {/* T/F */} -
- handleSelect("tf", "T")} - /> - handleSelect("tf", "F")} - /> -
+
+ {/* 상단 행: E S F P */} +
+ handleSelect("ei", "E")} + > + E + + handleSelect("sn", "S")} + > + S + + handleSelect("tf", "F")} + > + F + + handleSelect("jp", "P")} + > + P + +
- {/* J/P */} -
- handleSelect("jp", "J")} - /> - handleSelect("jp", "P")} - /> + {/* 하단 행: I N T J */} +
+ handleSelect("ei", "I")} + > + I + + handleSelect("sn", "N")} + > + N + + handleSelect("tf", "T")} + > + T + + handleSelect("jp", "J")} + > + J + +
diff --git a/app/profile-builder/_components/Step4ContactFrequency.tsx b/app/profile-builder/_components/Step4ContactFrequency.tsx index 353f0cf..6615b24 100644 --- a/app/profile-builder/_components/Step4ContactFrequency.tsx +++ b/app/profile-builder/_components/Step4ContactFrequency.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import ProfileButton from "./ProfileButton"; interface Step4ContactFrequencyProps { onFrequencySelect: (frequency: string) => void; @@ -22,40 +23,25 @@ export default function Step4ContactFrequency({
-
- - - +
diff --git a/lib/constants/hobbies.ts b/lib/constants/hobbies.ts new file mode 100644 index 0000000..e90db66 --- /dev/null +++ b/lib/constants/hobbies.ts @@ -0,0 +1,104 @@ +export const HOBBIES = { + 스포츠: [ + "⚽ 축구", + "🏀 농구", + "⚾ 야구", + "🏐 배구", + "🎾 테니스", + "🏸 배드민턴", + "🏓 탁구", + "🎳 볼링", + "⛳ 골프", + "🏊 수영", + "🏃 러닝", + "⛰️ 등산", + "🏋️ 헬스", + "🧘 요가", + "🧗 클라이밍", + ], + 문화예술: [ + "🎬 영화감상", + "📺 드라마", + "🎭 뮤지컬", + "🎫 콘서트", + "🖼️ 전시회", + "📚 독서", + "✍️ 글쓰기", + "🎨 그림", + "📷 사진", + "🖌️ 캘리그라피", + "🧶 공예", + "👾 애니메이션", + "📱 웹툰", + "💃 댄스", + ], + 음악: [ + "🎤 K-POP", + "🎶 팝", + "🧢 힙합", + "🎵 R&B", + "🎸 록", + "🎷 재즈", + "🎻 클래식", + "🎧 인디", + "🎛️ EDM", + "🎹 발라드", + "🎸 기타연주", + "🎹 피아노", + "🥁 드럼", + "🎤 노래", + "🎼 작곡", + ], + 여행: [ + "✈️ 여행", + "⛺ 캠핑", + "🎣 낚시", + "☕ 카페투어", + "🍽️ 맛집탐방", + "🛍️ 쇼핑", + "🍳 요리", + "🥐 베이킹", + "🐶 반려동물", + "🌿 원예", + "🚗 드라이브", + "🚲 자전거", + "🛹 스케이트보드", + "🏄 서핑", + "⛷️ 스키/보드", + ], + "일상/공부": [ + "📝 공부", + "💬 외국어", + "💻 코딩", + "📈 주식/투자", + "📜 자격증", + "❤️ 봉사활동", + "📒 일기쓰기", + "🧘♂️ 명상", + "✨ 자기관리", + "👗 패션", + "💄 뷰티/메이크업", + "🏠 인테리어", + "▶️ 유튜브", + "🎙️ 팟캐스트", + "📱 SNS", + "⌨️ 블로그", + "🚀 사이드프로젝트", + ], + 게임: [ + "⚔️ 리그오브레전드", + "🔫 발로란트", + "🛡️ 오버워치", + "🧱 마인크래프트", + "🍁 메이플스토리", + "🎮 콘솔게임", + "📱 모바일게임", + "🎲 보드게임", + "🧩 퍼즐게임", + "♨️ 스팀게임", + "🍄 닌텐도", + ], +} as const; + +export type HobbyCategory = keyof typeof HOBBIES; +export type HobbyList = (typeof HOBBIES)[HobbyCategory]; diff --git a/lib/types/profile.ts b/lib/types/profile.ts index 616a290..7f1ac92 100644 --- a/lib/types/profile.ts +++ b/lib/types/profile.ts @@ -36,10 +36,12 @@ export interface ProfileData { // 학교 정보 university?: string; + department?: string; major?: string; // 기타 contactFrequency?: ContactFrequency; + hobbies?: string[]; } // 백엔드 전송용 타입 (필수 필드) @@ -55,4 +57,5 @@ export interface ProfileSubmitData { university: string; major: string; contactFrequency: ContactFrequency; + hobbies: string[]; } From 742478ec67ce175239404a8ce3fd0660f48a07cf Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 27 Feb 2026 15:58:10 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EA=B8=B0=EB=8A=A5=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env b/.env index 5b790e2..dcd104e 100644 --- a/.env +++ b/.env @@ -12,3 +12,8 @@ NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your-measurement-id NEXT_PUBLIC_FIREBASE_VAPID_KEY=your-vapid-key # 로컬 개발 환경 설정은 .env.local 파일에 작성하세요 + + +# https://www.builder.io/c/docs/using-your-api-key +NEXT_PUBLIC_BUILDER_API_KEY=4004cae858fa419b95f8ce17a0c3fbd1 + From 6219f03cc35dad858ffbe0ef8ba15e86e9940d08 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 27 Feb 2026 16:30:49 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 - app/hobby-select/_components/HobbyButton.tsx | 39 +++++ .../_components/HobbySearchInput.tsx | 37 +++++ .../_components/ScreenHobbySelect.tsx | 118 ++++++++++----- .../_components/ScreenProfileBuilder.tsx | 139 +++++++++++++----- lib/utils/hangul.ts | 45 ++++++ 6 files changed, 300 insertions(+), 81 deletions(-) create mode 100644 app/hobby-select/_components/HobbyButton.tsx create mode 100644 app/hobby-select/_components/HobbySearchInput.tsx create mode 100644 lib/utils/hangul.ts diff --git a/.env b/.env index dcd104e..8ceaf21 100644 --- a/.env +++ b/.env @@ -14,6 +14,3 @@ NEXT_PUBLIC_FIREBASE_VAPID_KEY=your-vapid-key # 로컬 개발 환경 설정은 .env.local 파일에 작성하세요 -# https://www.builder.io/c/docs/using-your-api-key -NEXT_PUBLIC_BUILDER_API_KEY=4004cae858fa419b95f8ce17a0c3fbd1 - diff --git a/app/hobby-select/_components/HobbyButton.tsx b/app/hobby-select/_components/HobbyButton.tsx new file mode 100644 index 0000000..2c35da7 --- /dev/null +++ b/app/hobby-select/_components/HobbyButton.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Plus } from "lucide-react"; + +interface HobbyButtonProps { + children: React.ReactNode; + onClick?: () => void; + selected?: boolean; + plus?: boolean; +} + +const HobbyButton = ({ + children, + onClick, + selected, + plus, +}: HobbyButtonProps) => { + const baseClass = + "typo-14-500 flex items-center gap-1 rounded-full border px-3 py-[7.5px] whitespace-nowrap text-black transition-all duration-200 ease-in-out"; + const selectedClass = selected + ? "border-[#FF4D61] bg-[#FFEBED]" + : "border-[#DFDFDF] bg-[#B3B3B3]/15"; + + return ( + + ); +}; + +export default HobbyButton; diff --git a/app/hobby-select/_components/HobbySearchInput.tsx b/app/hobby-select/_components/HobbySearchInput.tsx new file mode 100644 index 0000000..09525c3 --- /dev/null +++ b/app/hobby-select/_components/HobbySearchInput.tsx @@ -0,0 +1,37 @@ +import React, { FormEvent, useRef } from "react"; +import { Search } from "lucide-react"; + +interface HobbySearchInputProps { + onSearch: (keyword: string) => void; +} + +const HobbySearchInput = ({ onSearch }: HobbySearchInputProps) => { + const inputRef = useRef(null); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (inputRef.current) { + onSearch(inputRef.current.value); + inputRef.current.blur(); + } + }; + + return ( +
+ + +
+ ); +}; + +export default HobbySearchInput; diff --git a/app/hobby-select/_components/ScreenHobbySelect.tsx b/app/hobby-select/_components/ScreenHobbySelect.tsx index 03c4061..34959ff 100644 --- a/app/hobby-select/_components/ScreenHobbySelect.tsx +++ b/app/hobby-select/_components/ScreenHobbySelect.tsx @@ -1,6 +1,8 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; +import { Plus } from "lucide-react"; +import HobbyButton from "./HobbyButton"; import { useRouter } from "next/navigation"; import { BackButton } from "@/components/ui/BackButton"; import Button from "@/components/ui/Button"; @@ -9,24 +11,32 @@ import ProgressStepBar from "@/components/ui/ProgressStepBar"; import Blur from "@/components/common/Blur"; import { HOBBIES, type HobbyCategory } from "@/lib/constants/hobbies"; +import HobbySearchInput from "./HobbySearchInput"; +import { createChoseongRegex } from "@/lib/utils/hangul"; + +const ALL_HOBBIES = Object.values(HOBBIES).flat(); const ScreenHobbySelect = () => { const router = useRouter(); const { profile, updateProfile } = useProfile(); const [selected, setSelected] = useState(profile.hobbies || []); + const [searchKeyword, setSearchKeyword] = useState(""); const toggleHobby = (hobby: string) => { + // alert 중복 방지: selected.length 기준으로 체크 + const isAlreadySelected = selected.includes(hobby); + if (!isAlreadySelected && selected.length >= 10) { + console.log("ALERT: 10개 초과 시도", selected); + alert("최대 10개까지 선택할 수 있어요."); + return; + } setSelected((prev) => { - const isAlreadySelected = prev.includes(hobby); - - if (!isAlreadySelected && prev.length >= 10) { - alert("최대 10개까지 선택할 수 있어요."); - return prev; + const isAlreadySelectedInner = prev.includes(hobby); + if (isAlreadySelectedInner) { + // 선택 해제는 무조건 허용 + return prev.filter((h) => h !== hobby); } - - return isAlreadySelected - ? prev.filter((h) => h !== hobby) - : [...prev, hobby]; + return [...prev, hobby]; }); }; @@ -41,16 +51,18 @@ const ScreenHobbySelect = () => { router.push("/extra-info"); }; - return ( -
- + const filteredHobbies = useMemo(() => { + if (!searchKeyword.trim()) return null; // No search term -
- - -
+ // Normalize and create regex for chosung matching + const searchRegex = createChoseongRegex(searchKeyword.trim()); + return ALL_HOBBIES.filter((hobby) => searchRegex.test(hobby)); + }, [searchKeyword]); -
+ return ( +
+ +

관심사를 알려주세요.

요즘 관심있는 것들을 3개 이상 선택해주세요. @@ -58,32 +70,58 @@ const ScreenHobbySelect = () => { 최대 10개까지 선택할 수 있어요.

+ -
- {(Object.keys(HOBBIES) as HobbyCategory[]).map((category) => ( -
-

{category}

+
+ {filteredHobbies ? ( +
+

검색 결과

- {HOBBIES[category].map((hobby) => { - const isSelected = selected.includes(hobby); - return ( - - ); - })} + {filteredHobbies.length > 0 ? ( + filteredHobbies.map((hobby) => { + const isSelected = selected.includes(hobby); + return ( + toggleHobby(hobby)} + selected={isSelected} + > + {hobby} + + ); + }) + ) : ( +

+ 검색된 관심사가 없습니다. +

+ )}
- ))} + ) : ( + (Object.keys(HOBBIES) as HobbyCategory[]).map((category) => ( +
+

{category}

+
+ {HOBBIES[category].map((hobby) => { + const isSelected = selected.includes(hobby); + return ( + toggleHobby(hobby)} + selected={isSelected} + > + {hobby} + + ); + })} +
+
+ )) + )} +
+
+

내가 좋아하는 관심사가 없나요?

+ 내 관심사 추가하기