diff --git a/.env b/.env index 5b790e2..8ceaf21 100644 --- a/.env +++ b/.env @@ -12,3 +12,5 @@ NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your-measurement-id NEXT_PUBLIC_FIREBASE_VAPID_KEY=your-vapid-key # 로컬 개발 환경 설정은 .env.local 파일에 작성하세요 + + 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/HobbyButton.tsx b/app/hobby-select/_components/HobbyButton.tsx new file mode 100644 index 0000000..1496c6e --- /dev/null +++ b/app/hobby-select/_components/HobbyButton.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface HobbyButtonProps { + children: React.ReactNode; + onClick?: () => void; + selected?: boolean; + plus?: boolean; +} + +const HobbyButton = ({ + children, + onClick, + selected, + plus, +}: HobbyButtonProps) => { + 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 new file mode 100644 index 0000000..34959ff --- /dev/null +++ b/app/hobby-select/_components/ScreenHobbySelect.tsx @@ -0,0 +1,143 @@ +"use client"; + +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"; +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"; +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 isAlreadySelectedInner = prev.includes(hobby); + if (isAlreadySelectedInner) { + // 선택 해제는 무조건 허용 + return prev.filter((h) => h !== hobby); + } + return [...prev, hobby]; + }); + }; + + const handleComplete = () => { + // Context 업데이트 + updateProfile({ + ...profile, + hobbies: selected, + }); + + // 다음 페이지로 이동 (회원가입/추가 정보 입력 등) + router.push("/extra-info"); + }; + + 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개 이상 선택해주세요. +
+ 최대 10개까지 선택할 수 있어요. +

+
+ + +
+ {filteredHobbies ? ( +
+

검색 결과

+
+ {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} + + ); + })} +
+
+ )) + )} +
+
+

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

+ 내 관심사 추가하기 +
+ + +
+ ); +}; + +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/layout.tsx b/app/layout.tsx index 0fe0362..9de385d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,8 +3,9 @@ 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"; +// import { ServiceStatusProvider } from "@/providers/service-status-provider"; +// import { getInitialMaintenanceStatus } from "@/lib/status"; const pretendard = localFont({ src: "./fonts/PretendardVariable.woff2", @@ -54,7 +55,7 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const initialMaintenanceMode = await getInitialMaintenanceStatus(); + // const initialMaintenanceMode = await getInitialMaintenanceStatus(); return ( @@ -62,14 +63,16 @@ export default async function RootLayout({ className={`${pretendard.className} flex justify-center bg-white antialiased`} > - {/* */} -
- - {children} -
- {/*
*/} + + {/* */} +
+ + {children} +
+ {/*
*/} +
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/ProfileButton.tsx b/app/profile-builder/_components/ProfileButton.tsx new file mode 100644 index 0000000..227d6a3 --- /dev/null +++ b/app/profile-builder/_components/ProfileButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; + +interface ProfileButtonProps { + children: React.ReactNode; + selected?: boolean; + onClick?: () => void; +} + +export default function ProfileButton({ + children, + selected = false, + onClick, +}: ProfileButtonProps) { + return ( + + ); +} diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx new file mode 100644 index 0000000..7cd687a --- /dev/null +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -0,0 +1,224 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import Button from "@/components/ui/Button"; +import { useProfile } from "@/providers/profile-provider"; +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"; +import Step3MBTI from "./Step3MBTI"; +import Step4ContactFrequency from "./Step4ContactFrequency"; +import { + ContactFrequency, + Gender, + MBTI, + ProfileData, +} from "@/lib/types/profile"; + +const genderMap: Record = { + 남자: "MALE", + 여자: "FEMALE", +}; + +const contactFrequencyMap: Record = { + 자주: "FREQUENT", + 보통: "NORMAL", + 적음: "RARE", +}; + +export const ScreenProfileBuilder = () => { + const router = useRouter(); + const { profile, updateProfile, isReady } = useProfile(); + + // Derive initial values from profile or localStorage synchronously + const getInitialValues = () => { + if (profile) { + return { + birthYear: profile.birthDate ? profile.birthDate.split("-")[0] : "", + university: profile.university || "", + department: profile.department || "", + major: profile.major || "", + gender: + Object.keys(genderMap).find( + (key) => genderMap[key] === profile.gender, + ) || "", + mbti: profile.mbti || "", + frequency: + Object.keys(contactFrequencyMap).find( + (key) => contactFrequencyMap[key] === profile.contactFrequency, + ) || "", + }; + } + try { + const saved = localStorage.getItem("profileBuilder"); + if (saved) return JSON.parse(saved); + } catch { + // ignore + } + return {}; + }; + + const initialValues = getInitialValues(); + const allFilled = Boolean( + initialValues.birthYear && + initialValues.university && + initialValues.department && + initialValues.major && + initialValues.gender && + initialValues.mbti && + initialValues.frequency, + ); + + const [currentStep, setCurrentStep] = useState(allFilled ? 4 : 1); + const [selectedBirthYear, setSelectedBirthYear] = useState( + initialValues.birthYear || "", + ); + const [selectedUniversity, setSelectedUniversity] = useState( + initialValues.university || "", + ); + const [selectedDepartment, setSelectedDepartment] = useState( + initialValues.department || "", + ); + const [selectedMajor, setSelectedMajor] = useState( + initialValues.major || "", + ); + const [selectedGender, setSelectedGender] = useState( + initialValues.gender || "", + ); + const [selectedMBTI, setSelectedMBTI] = useState( + initialValues.mbti || "", + ); + const [selectedFrequency, setSelectedFrequency] = useState( + initialValues.frequency || "", + ); + + // isReady ref — kept to avoid re-running on profile change during session + const initializedRef = useRef(false); + useEffect(() => { + if (!isReady || initializedRef.current) return; + initializedRef.current = true; + // Values are already set via lazy init above; nothing extra needed here + }, [isReady]); + + const yearOptions = getYearOptions(); + const universityOptions = getUniversityOptions(universities); + const departmentOptions = getDepartmentOptions( + selectedUniversity, + majorCategories, + ); + const majorOptions = getMajorOptions( + selectedUniversity, + selectedDepartment, + majorCategories, + ); + + const handleNext = () => { + if (currentStep < 4) { + setCurrentStep(currentStep + 1); + } + }; + + 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 ( +
+ {/* 헤더 영역 */} + +
+

+ {getStepTitle(currentStep)} +

+

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

+
+ + {/* 폼 영역 */} +
+ {/* Step 4: Contact Frequency */} + {currentStep >= 4 && ( + + )} + + {/* Step 3: MBTI */} + {currentStep >= 3 && ( + + )} + + {/* Step 2: Gender */} + {currentStep >= 2 && ( + + )} + + {/* Step 1: Basic */} + { + setSelectedDepartment(value); + setSelectedMajor(""); + }} + onMajorChange={setSelectedMajor} + /> +
+ + {/* 하단 고정 버튼 */} + +
+ ); +}; diff --git a/app/profile-builder/_components/Step1Basic.tsx b/app/profile-builder/_components/Step1Basic.tsx new file mode 100644 index 0000000..d53196d --- /dev/null +++ b/app/profile-builder/_components/Step1Basic.tsx @@ -0,0 +1,105 @@ +"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[]; + selectedBirthYear: string; + selectedUniversity: string; + selectedDepartment: string; + selectedMajor: string; + onBirthYearChange: (value: string) => void; + onUniversityChange: (value: string) => void; + onDepartmentChange: (value: string) => void; + onMajorChange: (value: string) => void; +} + +export default function Step1Basic({ + yearOptions, + universityOptions, + departmentOptions, + majorOptions, + selectedBirthYear, + selectedUniversity, + selectedDepartment, + selectedMajor, + onBirthYearChange, + onUniversityChange, + onDepartmentChange, + onMajorChange, +}: Step1BasicProps) { + return ( +
+ {/* 나이 */} +
+ + onBirthYearChange(e.target.value)} + /> +
+ + {/* 학교 */} +
+ + onUniversityChange(e.target.value)} + /> +
+ + {/* 학과(계열) / 전공 */} +
+
+ + onDepartmentChange(e.target.value)} + disabled={!selectedUniversity} + /> +
+
+ + onMajorChange(e.target.value)} + disabled={!selectedDepartment} + /> +
+
+
+ ); +} diff --git a/app/profile-builder/_components/Step2Gender.tsx b/app/profile-builder/_components/Step2Gender.tsx new file mode 100644 index 0000000..dabe227 --- /dev/null +++ b/app/profile-builder/_components/Step2Gender.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React, { useState } from "react"; +import ProfileButton from "./ProfileButton"; + +// GenderButton 컴포넌트 제거 + +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 ( +
+
+ +
+ handleSelect("남자")} + selected={selected === "남자"} + > + 남자 + + handleSelect("여자")} + selected={selected === "여자"} + > + 여자 + +
+
+ +
+ ); +} diff --git a/app/profile-builder/_components/Step3MBTI.tsx b/app/profile-builder/_components/Step3MBTI.tsx new file mode 100644 index 0000000..c245a93 --- /dev/null +++ b/app/profile-builder/_components/Step3MBTI.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { useState } from "react"; +import ProfileButton from "./ProfileButton"; + +interface Step3MBTIProps { + onMBTISelect: (mbti: string) => void; + defaultValue?: string; +} + +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); + + 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 S F P */} +
+ handleSelect("ei", "E")} + > + E + + handleSelect("sn", "S")} + > + S + + handleSelect("tf", "F")} + > + F + + handleSelect("jp", "P")} + > + 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 new file mode 100644 index 0000000..6615b24 --- /dev/null +++ b/app/profile-builder/_components/Step4ContactFrequency.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React, { useState } from "react"; +import ProfileButton from "./ProfileButton"; + +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 ( +
+
+ +
+ handleSelect("자주")} + > + 자주 + + handleSelect("보통")} + > + 보통 + + handleSelect("적음")} + > + 적음 + +
+
+ +
+ ); +} 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 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 ; +} 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..71b7d0c 100644 --- a/components/ui/FormSelect.tsx +++ b/components/ui/FormSelect.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React, { useState, useRef, useEffect } from "react"; +import Image from "next/image"; import { cn } from "@/lib/utils"; -import { ChevronDown } from "lucide-react"; // 옵션 타입 정의 (일반적인 단일 옵션) export type SelectOption = { @@ -15,14 +15,18 @@ export type SelectOptionGroup = { }; interface FormSelectProps extends Omit< - React.SelectHTMLAttributes, - "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-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 flex items-center"; // 타입 가드: 옵션 그룹인지 확인 function isOptionGroup( @@ -48,54 +52,183 @@ const FormSelect = ({ className = "", style = {}, error = false, + value, + defaultValue, + onChange, + disabled = false, ...rest }: FormSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // uncontrolled 상태 관리를 위한 내부 상태 + const [internalValue, setInternalValue] = useState(defaultValue || ""); + + // 제어/비제어 값 결정 + const isControlled = value !== undefined; + const currentValue = isControlled ? value : internalValue; + + const handleSelect = (selectedValue: string) => { + setIsOpen(false); + setInternalValue(selectedValue); + + if (onChange) { + const event = { + target: { name, value: selectedValue }, + } as React.ChangeEvent; + onChange(event); + } + }; + + // 외부 영역 클릭 시 드롭다운 닫기 + 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 && ( +
+ 선택할 수 있는 항목이 없습니다. +
+ )} +
+ )}
); }; 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..189fc74 --- /dev/null +++ b/lib/actions/profileBuilderAction.ts @@ -0,0 +1,129 @@ +"use server"; + +import { + ProfileData, + ContactFrequency, + Gender, + MBTI, +} from "@/lib/types/profile"; + +export type ProfileBuilderState = { + success: boolean; + message?: string; + data?: Partial; + errors?: { + birthYear?: boolean; + 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"] = {}; + + if (!birthYear) { + errors.birthYear = true; + } + + if (!university) { + errors.university = true; + } + + if (!department) { + errors.department = true; + } + + if (!major) { + 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, + errors, + }; + } + + // birthYear를 birthDate 형식으로 변환 (YYYY-MM-DD) + // 예: 2000 -> 2000-01-01 (임시로 1월 1일로 설정, 나중에 정확한 날짜 입력받을 수 있음) + const birthDate = `${birthYear}-01-01`; + + const profileData: Partial = { + birthDate, + gender, + mbti, + major, + university, + contactFrequency, + }; + + // 성공 시 데이터와 함께 반환 + return { + success: true, + data: profileData, + }; +} 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/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..7f1ac92 --- /dev/null +++ b/lib/types/profile.ts @@ -0,0 +1,61 @@ +// 온보딩 프로필 데이터 타입 정의 + +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" | "NORMAL" | "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; + department?: string; + major?: string; + + // 기타 + contactFrequency?: ContactFrequency; + hobbies?: string[]; +} + +// 백엔드 전송용 타입 (필수 필드) +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; + hobbies: string[]; +} diff --git a/lib/utils/hangul.ts b/lib/utils/hangul.ts new file mode 100644 index 0000000..84620dc --- /dev/null +++ b/lib/utils/hangul.ts @@ -0,0 +1,45 @@ +const CHO_SUNG = [ + "ㄱ", + "ㄲ", + "ㄴ", + "ㄷ", + "ㄸ", + "ㄹ", + "ㅁ", + "ㅂ", + "ㅃ", + "ㅅ", + "ㅆ", + "ㅇ", + "ㅈ", + "ㅉ", + "ㅊ", + "ㅋ", + "ㅌ", + "ㅍ", + "ㅎ", +]; + +const HANGUL_START = 0xac00; + +/** + * 주어진 검색어(초성 포함)를 바탕으로 정규식을 생성합니다. + * 예: "가ㄱ" -> /가[ㄱ가-깋]/i + */ +export function createChoseongRegex(searchWord: string): RegExp { + const regexString = searchWord + .split("") + .map((char) => { + const choIndex = CHO_SUNG.indexOf(char); + if (choIndex !== -1) { + const startChar = String.fromCharCode(HANGUL_START + choIndex * 588); + const endChar = String.fromCharCode( + HANGUL_START + (choIndex + 1) * 588 - 1, + ); + return `[${char}${startChar}-${endChar}]`; + } + return char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }) + .join(""); + return new RegExp(regexString, "i"); +} 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 @@ + + +