diff --git a/app/extra-info/detail/_components/ScreenExtraInfoDetail.tsx b/app/extra-info/detail/_components/ScreenExtraInfoDetail.tsx index baec7d8..eca4d5e 100644 --- a/app/extra-info/detail/_components/ScreenExtraInfoDetail.tsx +++ b/app/extra-info/detail/_components/ScreenExtraInfoDetail.tsx @@ -7,13 +7,14 @@ import FormInput from "@/components/ui/FormInput"; import ProgressStepBar from "@/components/ui/ProgressStepBar"; import AdvantageDrawer from "./AdvantageDrawer"; import { cn, removeEmoji } from "@/lib/utils"; -import { useProfile } from "@/providers/profile-provider"; +import { useProfileStore } from "@/stores/profile-store"; import { SocialType } from "@/lib/types/profile"; import { useRouter } from "next/navigation"; const ScreenExtraInfoDetail = () => { const router = useRouter(); - const { profile, updateProfile } = useProfile(); + const profile = useProfileStore((state) => state.profile); + const updateProfile = useProfileStore((state) => state.updateProfile); const [contactType, setContactType] = useState<"instagram" | "kakao" | null>( (profile.socialType?.toLowerCase() as "instagram" | "kakao") || null, diff --git a/app/globals.css b/app/globals.css index 549d47d..2506853 100644 --- a/app/globals.css +++ b/app/globals.css @@ -136,6 +136,16 @@ ); } + .bg-pink-gradient { + background: + linear-gradient( + 288.98deg, + rgba(255, 98, 95, 0.051694) 19.94%, + rgba(232, 58, 188, 0.15) 105.37% + ), + rgba(255, 255, 255, 0.2); + } + /* 스크롤바 숨기기 유틸리티 */ .scrollbar-hide { -ms-overflow-style: none; /* IE and Edge */ diff --git a/app/hobby-select/_components/ScreenHobbySelect.tsx b/app/hobby-select/_components/ScreenHobbySelect.tsx index 9df13e8..2e7fa67 100644 --- a/app/hobby-select/_components/ScreenHobbySelect.tsx +++ b/app/hobby-select/_components/ScreenHobbySelect.tsx @@ -4,7 +4,7 @@ import React, { useState, useMemo } from "react"; import HobbyButton from "./HobbyButton"; import { useRouter } from "next/navigation"; import Button from "@/components/ui/Button"; -import { useProfile } from "@/providers/profile-provider"; +import { useProfileStore } from "@/stores/profile-store"; import ProgressStepBar from "@/components/ui/ProgressStepBar"; import { HOBBIES, type HobbyCategory } from "@/lib/constants/hobbies"; @@ -17,7 +17,8 @@ const ALL_HOBBIES = Object.values(HOBBIES).flat() as string[]; const ScreenHobbySelect = () => { const router = useRouter(); - const { profile, updateProfile } = useProfile(); + const profile = useProfileStore((state) => state.profile); + const updateProfile = useProfileStore((state) => state.updateProfile); // 취미 이름으로 카테고리를 찾는 헬퍼 함수 const findCategoryByHobbyName = (name: string): HobbyCategory => { diff --git a/app/layout.tsx b/app/layout.tsx index 6230968..eea142d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,6 @@ import localFont from "next/font/local"; 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"; import FcmInitializer from "@/components/common/FcmInitializer"; @@ -68,13 +67,11 @@ export default async function RootLayout({ {/* */} - -
- - - {children} -
-
+
+ + + {children} +
{/*
*/} diff --git a/app/profile-builder/_components/ScreenProfileBuilder.tsx b/app/profile-builder/_components/ScreenProfileBuilder.tsx index 5d46d8c..dd3042a 100644 --- a/app/profile-builder/_components/ScreenProfileBuilder.tsx +++ b/app/profile-builder/_components/ScreenProfileBuilder.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState, useEffect } from "react"; +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 { useProfileStore } from "@/stores/profile-store"; import { majorCategories, universities } from "@/lib/constants/majors"; import { getDepartmentOptions, @@ -57,12 +57,25 @@ const mbtiSet = new Set([ const isValidMBTI = (mbti?: string): mbti is MBTI => mbtiSet.has((mbti || "").toUpperCase() as MBTI); -const PROFILE_STORAGE_KEY = "onboarding-profile-data"; -const LEGACY_PROFILE_STORAGE_KEY = "profileBuilder"; +const mapProfileToInitialValues = (profile: Partial) => ({ + birthYear: profile.birthDate ? profile.birthDate.split("-")[0] : "", + university: profile.university || "", + department: profile.department || "", + major: profile.major || "", + gender: + Object.keys(genderMap).find((k) => genderMap[k] === profile.gender) || "", + mbti: profile.mbti || "", + frequency: + Object.keys(contactFrequencyMap).find( + (k) => contactFrequencyMap[k] === profile.contactFrequency, + ) || "", +}); export const ScreenProfileBuilder = () => { const router = useRouter(); - const { profile, updateProfile, isReady } = useProfile(); + const profile = useProfileStore((state) => state.profile); + const updateProfile = useProfileStore((state) => state.updateProfile); + const isReady = useProfileStore((state) => state.isReady); const [currentStep, setCurrentStep] = useState(1); const [selectedBirthYear, setSelectedBirthYear] = useState(""); @@ -72,72 +85,12 @@ export const ScreenProfileBuilder = () => { const [selectedGender, setSelectedGender] = useState(""); const [selectedMBTI, setSelectedMBTI] = useState(""); const [selectedFrequency, setSelectedFrequency] = useState(""); - const [hasSelectedGender, setHasSelectedGender] = useState(false); - const [hasSelectedMBTI, setHasSelectedMBTI] = useState(false); - const [hasSelectedFrequency, setHasSelectedFrequency] = useState(false); - - const getInitialValues = () => { - try { - const savedProfile = localStorage.getItem(PROFILE_STORAGE_KEY); - if (savedProfile) { - const parsed = JSON.parse(savedProfile) as Partial; - return { - birthYear: parsed.birthDate ? parsed.birthDate.split("-")[0] : "", - university: parsed.university || "", - department: parsed.department || "", - major: parsed.major || "", - gender: - Object.keys(genderMap).find( - (k) => genderMap[k] === parsed.gender, - ) || "", - mbti: parsed.mbti || "", - frequency: - Object.keys(contactFrequencyMap).find( - (k) => contactFrequencyMap[k] === parsed.contactFrequency, - ) || "", - }; - } - - const legacySaved = localStorage.getItem(LEGACY_PROFILE_STORAGE_KEY); - if (legacySaved) return JSON.parse(legacySaved); - } catch { - // ignore - } - - if (profile && Object.keys(profile).length > 0) { - return { - birthYear: profile.birthDate ? profile.birthDate.split("-")[0] : "", - university: profile.university || "", - department: profile.department || "", - major: profile.major || "", - gender: - Object.keys(genderMap).find((k) => genderMap[k] === profile.gender) || - "", - mbti: profile.mbti || "", - frequency: - Object.keys(contactFrequencyMap).find( - (k) => contactFrequencyMap[k] === profile.contactFrequency, - ) || "", - }; - } - - return {}; - }; + const hasInitialized = useRef(false); useEffect(() => { - if (!isReady) return; - - const initialValues = getInitialValues(); - const allFilled = Boolean( - initialValues.birthYear && - initialValues.university && - initialValues.department && - initialValues.major && - initialValues.gender && - initialValues.mbti && - initialValues.frequency, - ); + if (!isReady || hasInitialized.current) return; + const initialValues = mapProfileToInitialValues(profile); const timeoutId = setTimeout(() => { if (initialValues.birthYear) setSelectedBirthYear(initialValues.birthYear); @@ -148,22 +101,32 @@ export const ScreenProfileBuilder = () => { if (initialValues.major) setSelectedMajor(initialValues.major); if (initialValues.gender) { setSelectedGender(initialValues.gender); - setHasSelectedGender(true); } if (initialValues.mbti) { setSelectedMBTI(initialValues.mbti); - setHasSelectedMBTI(true); } if (initialValues.frequency) { setSelectedFrequency(initialValues.frequency); - setHasSelectedFrequency(true); } - if (allFilled) setCurrentStep(4); + // 모든 정보가 이미 있다면 Step 4까지 모두 펼쳐줌 + if ( + initialValues.birthYear && + initialValues.university && + initialValues.department && + initialValues.major && + initialValues.gender && + initialValues.mbti && + initialValues.frequency + ) { + setCurrentStep(4); + } + + hasInitialized.current = true; }, 0); return () => clearTimeout(timeoutId); - }, [isReady]); + }, [isReady, profile]); const yearOptions = getYearOptions(); const universityOptions = getUniversityOptions(universities); @@ -202,17 +165,14 @@ export const ScreenProfileBuilder = () => { const handleGenderSelect = (value: string) => { setSelectedGender(value); - setHasSelectedGender(true); }; const handleMBTISelect = (value: string) => { setSelectedMBTI(value); - setHasSelectedMBTI(true); }; const handleFrequencySelect = (value: string) => { setSelectedFrequency(value); - setHasSelectedFrequency(true); }; const isStepValid = (() => { @@ -225,11 +185,11 @@ export const ScreenProfileBuilder = () => { selectedMajor ); case 2: - return !!selectedGender && hasSelectedGender; + return !!selectedGender; case 3: - return isValidMBTI(selectedMBTI) && hasSelectedMBTI; + return isValidMBTI(selectedMBTI); case 4: - return !!selectedFrequency && hasSelectedFrequency; + return !!selectedFrequency; default: return false; } @@ -280,13 +240,19 @@ export const ScreenProfileBuilder = () => { selectedUniversity={selectedUniversity} selectedDepartment={selectedDepartment} selectedMajor={selectedMajor} - onBirthYearChange={setSelectedBirthYear} - onUniversityChange={setSelectedUniversity} + onBirthYearChange={(value) => { + setSelectedBirthYear(value); + }} + onUniversityChange={(value) => { + setSelectedUniversity(value); + }} onDepartmentChange={(value) => { setSelectedDepartment(value); setSelectedMajor(""); }} - onMajorChange={setSelectedMajor} + onMajorChange={(value) => { + setSelectedMajor(value); + }} /> @@ -298,7 +264,6 @@ export const ScreenProfileBuilder = () => { safeArea disabled={!isStepValid} onClick={currentStep === 4 ? handleComplete : handleNext} - className="bg-button-primary text-button-primary-text-default" > {currentStep === 4 ? "완료" : "다음으로"} diff --git a/app/profile-builder/_components/Step3MBTI.tsx b/app/profile-builder/_components/Step3MBTI.tsx index c245a93..10b40db 100644 --- a/app/profile-builder/_components/Step3MBTI.tsx +++ b/app/profile-builder/_components/Step3MBTI.tsx @@ -29,9 +29,8 @@ export default function Step3MBTI({ (category === "tf" ? value : tf) + (category === "jp" ? value : jp); - if (newMBTI.length === 4) { - onMBTISelect(newMBTI); - } + // Always sync partial/complete selection so parent step validity updates immediately. + onMBTISelect(newMBTI); }; return ( diff --git a/app/profile-image/_components/AgreeBottomSheet.tsx b/app/profile-image/_components/AgreeBottomSheet.tsx new file mode 100644 index 0000000..db238c3 --- /dev/null +++ b/app/profile-image/_components/AgreeBottomSheet.tsx @@ -0,0 +1,64 @@ +"use client"; +import React from "react"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerTitle, +} from "@/components/ui/drawer"; +import { X } from "lucide-react"; + +interface AgreeBottomSheetProps { + isOpen: boolean; + onClose: () => void; + title: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; +} + +const AgreeBottomSheet = ({ + isOpen, + onClose, + title, + description, + children, + footer, +}: AgreeBottomSheetProps) => { + return ( + !open && onClose()}> + +
+
+ + {title} + + {description && ( + + {description} + + )} +
+ +
+ +
+ {children} +
+ + {footer &&
{footer}
} +
+
+ ); +}; + +export default AgreeBottomSheet; diff --git a/app/profile-image/_components/DefaultProfileDrawer.tsx b/app/profile-image/_components/DefaultProfileDrawer.tsx index fea7192..aa5799e 100644 --- a/app/profile-image/_components/DefaultProfileDrawer.tsx +++ b/app/profile-image/_components/DefaultProfileDrawer.tsx @@ -3,40 +3,35 @@ import React, { useState } from "react"; import Image from "next/image"; import Button from "@/components/ui/Button"; import ProfileBottomSheet from "./ProfileBottomSheet"; - -export const DEFAULT_PROFILES = [ - { id: "dog", name: "강아지", image: "/profile/dog.svg" }, - { id: "cat", name: "고양이", image: "/profile/cat.svg" }, - { id: "bear", name: "곰", image: "/profile/bear.svg" }, - { id: "rabbit", name: "토끼", image: "/profile/rabbit.svg" }, - { id: "fox", name: "여우", image: "/profile/fox.svg" }, - { id: "penguin", name: "펭귄", image: "/profile/penguin.svg" }, - { id: "dinosaur", name: "공룡", image: "/profile/dinosaur.svg" }, - { id: "otter", name: "수달", image: "/profile/otter.svg" }, - { id: "wolf", name: "늑대", image: "/profile/wolf.svg" }, - { id: "snake", name: "뱀", image: "/profile/snake.svg" }, - { id: "horse", name: "말", image: "/profile/horse.svg" }, - { id: "frog", name: "개구리", image: "/profile/frog.svg" }, -]; +import type { Gender } from "@/lib/types/profile"; +import { getDefaultProfilesByGender } from "../_constants/defaultProfiles"; interface DefaultProfileDrawerProps { children: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void; }>; selectedProfile: string; + gender?: Gender; onSelect: (profileId: string) => void; } const DefaultProfileDrawer = ({ children, selectedProfile, + gender, onSelect, }: DefaultProfileDrawerProps) => { + const profiles = getDefaultProfilesByGender(gender); + const firstProfileId = profiles[0]?.id || ""; + const [selected, setSelected] = useState(selectedProfile); const [isOpen, setIsOpen] = useState(false); const openDrawer = () => { - setSelected(selectedProfile); + const hasSelected = profiles.some( + (profile) => profile.id === selectedProfile, + ); + setSelected(hasSelected ? selectedProfile : firstProfileId); setIsOpen(true); }; @@ -80,33 +75,33 @@ const DefaultProfileDrawer = ({ } > -
- {DEFAULT_PROFILES.map((profile) => ( +
+ {profiles.map((profile) => (
-
- {children} -
+
{children}
- {footer &&
{footer}
} -
- + {footer &&
{footer}
} + + ); }; diff --git a/app/profile-image/_components/ProfileImageSelection.tsx b/app/profile-image/_components/ProfileImageSelection.tsx index f7239e1..ae0b97b 100644 --- a/app/profile-image/_components/ProfileImageSelection.tsx +++ b/app/profile-image/_components/ProfileImageSelection.tsx @@ -2,7 +2,9 @@ import React from "react"; import Image from "next/image"; import { convertHeicToJpg } from "@/lib/utils/image"; -import DefaultProfileDrawer, { DEFAULT_PROFILES } from "./DefaultProfileDrawer"; +import type { Gender } from "@/lib/types/profile"; +import DefaultProfileDrawer from "./DefaultProfileDrawer"; +import { getDefaultProfilesByGender } from "../_constants/defaultProfiles"; type SelectCheckButtonProps = { label: string; @@ -40,6 +42,7 @@ export const SelectCheckButton = ({ interface ProfileImageSelectionProps { selected: "default" | "custom"; onSelect: (type: "default" | "custom") => void; + gender?: Gender; selectedProfile: string; onProfileSelect: (profileId: string) => void; customImage: string | null; @@ -50,6 +53,7 @@ interface ProfileImageSelectionProps { const ProfileImageSelection = ({ selected, onSelect, + gender, selectedProfile, onProfileSelect, customImage, @@ -62,7 +66,11 @@ const ProfileImageSelection = ({ const actionLabel = selected === "default" ? "기본 이미지 변경" : "프로필 사진 변경"; - const currentProfile = DEFAULT_PROFILES.find((p) => p.id === selectedProfile); + const availableDefaultProfiles = getDefaultProfilesByGender(gender); + const currentProfile = availableDefaultProfiles.find( + (p) => p.id === selectedProfile, + ); + const resolvedProfile = currentProfile || availableDefaultProfiles[0]; const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -97,13 +105,14 @@ const ProfileImageSelection = ({ className="flex h-[100px] w-[100px] items-center justify-center rounded-full" onClick={() => onSelect("default")} > - {currentProfile ? ( - + {resolvedProfile ? ( + {currentProfile.name} ) : ( @@ -156,6 +165,7 @@ const ProfileImageSelection = ({
{selected === "default" ? ( diff --git a/app/profile-image/_components/ScreenProfileImage.tsx b/app/profile-image/_components/ScreenProfileImage.tsx index cf22cb5..d589bc3 100644 --- a/app/profile-image/_components/ScreenProfileImage.tsx +++ b/app/profile-image/_components/ScreenProfileImage.tsx @@ -1,16 +1,21 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import ProfileImageSelection from "./ProfileImageSelection"; import NicknameSection from "./NicknameSection"; import IntroSection from "./IntroSection"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import Button from "@/components/ui/Button"; import TermsDrawer from "./TermsDrawer"; -import { useProfile } from "@/providers/profile-provider"; +import { useProfileStore } from "@/stores/profile-store"; import { generateRandomNickname } from "@/lib/utils/nickname"; +import { getDefaultProfilesByGender } from "../_constants/defaultProfiles"; const ScreenProfileImage = () => { - const { profile, updateProfile } = useProfile(); + const router = useRouter(); + const profile = useProfileStore((state) => state.profile); + const updateProfile = useProfileStore((state) => state.updateProfile); + const isReady = useProfileStore((state) => state.isReady); // local preview states (not as critical to persist, but good for UX) const [selectedType, setSelectedType] = useState<"default" | "custom">( @@ -20,6 +25,35 @@ const ScreenProfileImage = () => { null, ); + const availableDefaultProfiles = useMemo( + () => getDefaultProfilesByGender(profile.gender), + [profile.gender], + ); + const fallbackProfileId = availableDefaultProfiles[0]?.id || "dog"; + + useEffect(() => { + if (!isReady) { + return; + } + + if (!profile.gender) { + alert("성별을 선택해주세요"); + router.replace("/profile-builder"); + } + }, [isReady, profile.gender, router]); + + // 저장된 profileImageUrl 유효성 확인: 없으면 기본값으로 설정 + useEffect(() => { + const selectedProfileId = profile.profileImageUrl; + if (selectedProfileId) { + // 이미 선택된 값이 있으면 (성별 변경해도 같은 동물id로 자동 전환) + return; + } + + // 저장값이 없으면 기본 첫 번째 프로필로 설정 + updateProfile({ profileImageUrl: fallbackProfileId }); + }, [fallbackProfileId, profile.profileImageUrl, updateProfile]); + const handleNicknameChange = (val: string) => updateProfile({ nickname: val }); const handleRandomNickname = () => { @@ -49,7 +83,8 @@ const ScreenProfileImage = () => { updateProfile({ profileImageUrl: id })} customImage={customImagePreview} onCustomImageChange={setCustomImagePreview} diff --git a/app/profile-image/_components/TermsDrawer.tsx b/app/profile-image/_components/TermsDrawer.tsx index 8d1f963..afd12dc 100644 --- a/app/profile-image/_components/TermsDrawer.tsx +++ b/app/profile-image/_components/TermsDrawer.tsx @@ -4,10 +4,10 @@ import { ChevronRight, ArrowLeft } from "lucide-react"; import axios from "axios"; import Button from "@/components/ui/Button"; import { SelectCheckButton } from "./ProfileImageSelection"; -import ProfileBottomSheet from "./ProfileBottomSheet"; +import AgreeBottomSheet from "./AgreeBottomSheet"; import { TERMS_TEXT, PRIVACY_TEXT } from "../_constants/terms"; import { Hobby, ProfileSubmitData } from "@/lib/types/profile"; -import { useProfile } from "@/providers/profile-provider"; +import { useProfileStore } from "@/stores/profile-store"; import { useImageUpload, useProfileSignUp } from "@/hooks/useProfileSignUp"; import { useNicknameAvailability, @@ -27,7 +27,8 @@ type ViewMode = "list" | "terms" | "privacy"; const TermsDrawer = ({ children }: TermsDrawerProps) => { const router = useRouter(); - const { profile, clearProfile } = useProfile(); + const profile = useProfileStore((state) => state.profile); + const clearProfile = useProfileStore((state) => state.clearProfile); const [isOpen, setIsOpen] = useState(false); const [viewMode, setViewMode] = useState("list"); const [agreements, setAgreements] = useState({ @@ -260,7 +261,7 @@ const TermsDrawer = ({ children }: TermsDrawerProps) => { <> {trigger} - { } > {renderContent()} - + ); }; diff --git a/app/profile-image/_constants/defaultProfiles.ts b/app/profile-image/_constants/defaultProfiles.ts new file mode 100644 index 0000000..1d9b438 --- /dev/null +++ b/app/profile-image/_constants/defaultProfiles.ts @@ -0,0 +1,136 @@ +import type { Gender } from "@/lib/types/profile"; + +export interface DefaultProfileAsset { + id: string; + name: string; + maleImage?: string; + femaleImage?: string; + fallbackImage?: string; +} + +type GenderImageKey = "maleImage" | "femaleImage"; + +const GENDER_PROFILE_RULES: Record< + Gender, + { imageKey: GenderImageKey; excludedIds: Set } +> = { + MALE: { imageKey: "maleImage", excludedIds: new Set(["snake"]) }, + FEMALE: { imageKey: "femaleImage", excludedIds: new Set(["horse"]) }, +}; + +export const DEFAULT_PROFILE_ASSETS: DefaultProfileAsset[] = [ + { + id: "dog", + name: "강아지", + maleImage: "/animal/dog_male%201.png", + femaleImage: "/animal/dog_female%201.png", + }, + { + id: "cat", + name: "고양이", + maleImage: "/animal/cat_male%201.png", + femaleImage: "/animal/cat_female%201.png", + }, + { + id: "bear", + name: "곰", + maleImage: "/animal/bear_male%201.png", + femaleImage: "/animal/bear_female%201.png", + }, + { + id: "fox", + name: "여우", + maleImage: "/animal/fox_male%201.png", + femaleImage: "/animal/fox_female%201.png", + }, + { + id: "rabbit", + name: "토끼", + maleImage: "/animal/rabbit_male%201.png", + femaleImage: "/animal/rabbit_female%201.png", + }, + { + id: "otter", + name: "수달", + maleImage: "/animal/otter_male%201.png", + femaleImage: "/animal/otter_female%201.png", + }, + { + id: "wolf", + name: "늑대", + maleImage: "/animal/Wolf_male%201.png", + femaleImage: "/animal/Wolf_female%201.png", + }, + { + id: "horse", + name: "말", + maleImage: "/animal/horse_male.png", + }, + { + id: "snake", + name: "뱀", + femaleImage: "/animal/snake_female.png", + }, + { + id: "dinosaur", + name: "공룡", + maleImage: "/animal/dinosaur%201.png", + femaleImage: "/animal/dinosaur%201.png", + fallbackImage: "/animal/dinosaur%201.png", + }, +]; + +export function getDefaultProfilesByGender(gender?: Gender) { + return DEFAULT_PROFILE_ASSETS.flatMap((profile) => { + if (gender) { + const rule = GENDER_PROFILE_RULES[gender]; + if (rule.excludedIds.has(profile.id)) { + return []; + } + const image = profile[rule.imageKey]; + if (!image) { + return []; + } + return [{ id: profile.id, name: profile.name, image }]; + } + + // Unknown gender: prefer female image when available so female-only profiles (e.g. snake) are visible. + if (profile.femaleImage) { + return [ + { id: profile.id, name: profile.name, image: profile.femaleImage }, + ]; + } + if (profile.fallbackImage) { + return [ + { id: profile.id, name: profile.name, image: profile.fallbackImage }, + ]; + } + return []; + }); +} + +/** + * 같은 동물 id 기준으로 반대 성별 이미지가 존재하는지 확인하고, 존재하면 id 반환 + */ +export function getAutoSwitchProfileIdByGender( + currentProfileId: string, + newGender?: Gender, +): string | null { + if (!newGender) { + return null; + } + + const asset = DEFAULT_PROFILE_ASSETS.find((p) => p.id === currentProfileId); + if (!asset) { + return null; + } + + const rule = GENDER_PROFILE_RULES[newGender]; + if (rule.excludedIds.has(currentProfileId)) { + return null; + } + + const hasImageForNewGender = asset[rule.imageKey]; + + return hasImageForNewGender ? currentProfileId : null; +} diff --git a/package.json b/package.json index abe87c6..8c0857f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.4.0", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zustand": "^5.0.12" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c44ab97..e58cb91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.10)(react@19.2.3) devDependencies: '@tailwindcss/postcss': specifier: ^4.1.18 @@ -2785,6 +2788,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -5747,3 +5768,8 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.10)(react@19.2.3): + optionalDependencies: + '@types/react': 19.2.10 + react: 19.2.3 diff --git a/providers/profile-provider.tsx b/providers/profile-provider.tsx deleted file mode 100644 index 23217c0..0000000 --- a/providers/profile-provider.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import React, { createContext, useContext, useState, useEffect } from "react"; -import { ProfileData } from "@/lib/types/profile"; - -const STORAGE_KEY = "onboarding-profile-data"; -const LEGACY_STORAGE_KEYS = ["profileBuilder"]; - -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({} as ProfileData); - 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({} as ProfileData); - try { - localStorage.removeItem(STORAGE_KEY); - LEGACY_STORAGE_KEYS.forEach((key) => localStorage.removeItem(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/providers/service-status-provider.tsx b/providers/service-status-provider.tsx index 6918b74..20ec4fd 100644 --- a/providers/service-status-provider.tsx +++ b/providers/service-status-provider.tsx @@ -1,9 +1,18 @@ "use client"; -import React, { createContext, useContext } from "react"; +import React, { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; +import { create } from "zustand"; -const ServiceStatusContext = createContext(undefined); +interface ServiceStatusStore { + isMaintenance: boolean; + setMaintenance: (value: boolean) => void; +} + +export const useServiceStatusStore = create((set) => ({ + isMaintenance: false, + setMaintenance: (value) => set({ isMaintenance: value }), +})); export function ServiceStatusProvider({ children, @@ -12,6 +21,8 @@ export function ServiceStatusProvider({ children: React.ReactNode; initialMaintenanceMode: boolean; }) { + const setMaintenance = useServiceStatusStore((state) => state.setMaintenance); + const { data: isMaintenance } = useQuery({ queryKey: ["maintenance-status"], queryFn: async () => { @@ -27,19 +38,14 @@ export function ServiceStatusProvider({ staleTime: 1000 * 20, }); - return ( - - {children} - - ); + useEffect(() => { + setMaintenance(isMaintenance ?? false); + }, [isMaintenance, setMaintenance]); + + return <>{children}; } export function useServiceStatus() { - const context = useContext(ServiceStatusContext); - if (context === undefined) { - throw new Error( - "useServiceStatus must be used within ServiceStatusProvider", - ); - } - return context; + // Backward-compatible alias for existing call sites. + return useServiceStatusStore((state) => state.isMaintenance); } diff --git a/public/animal/Wolf_female 1.png b/public/animal/Wolf_female 1.png new file mode 100644 index 0000000..e8aa13c Binary files /dev/null and b/public/animal/Wolf_female 1.png differ diff --git a/public/animal/Wolf_male 1.png b/public/animal/Wolf_male 1.png new file mode 100644 index 0000000..3ac852c Binary files /dev/null and b/public/animal/Wolf_male 1.png differ diff --git a/public/animal/bear_female 1.png b/public/animal/bear_female 1.png new file mode 100644 index 0000000..b46f4f6 Binary files /dev/null and b/public/animal/bear_female 1.png differ diff --git a/public/animal/bear_male 1.png b/public/animal/bear_male 1.png new file mode 100644 index 0000000..b2424f5 Binary files /dev/null and b/public/animal/bear_male 1.png differ diff --git a/public/animal/cat_female 1.png b/public/animal/cat_female 1.png new file mode 100644 index 0000000..b341f7a Binary files /dev/null and b/public/animal/cat_female 1.png differ diff --git a/public/animal/cat_male 1.png b/public/animal/cat_male 1.png new file mode 100644 index 0000000..e409e47 Binary files /dev/null and b/public/animal/cat_male 1.png differ diff --git a/public/animal/dinosaur 1.png b/public/animal/dinosaur 1.png new file mode 100644 index 0000000..a6c736b Binary files /dev/null and b/public/animal/dinosaur 1.png differ diff --git a/public/animal/dog_female 1.png b/public/animal/dog_female 1.png new file mode 100644 index 0000000..4299c5c Binary files /dev/null and b/public/animal/dog_female 1.png differ diff --git a/public/animal/dog_male 1.png b/public/animal/dog_male 1.png new file mode 100644 index 0000000..ea95120 Binary files /dev/null and b/public/animal/dog_male 1.png differ diff --git a/public/animal/fox_female 1.png b/public/animal/fox_female 1.png new file mode 100644 index 0000000..e01357a Binary files /dev/null and b/public/animal/fox_female 1.png differ diff --git a/public/animal/fox_male 1.png b/public/animal/fox_male 1.png new file mode 100644 index 0000000..0004a56 Binary files /dev/null and b/public/animal/fox_male 1.png differ diff --git a/public/animal/horse_male.png b/public/animal/horse_male.png new file mode 100644 index 0000000..dd09327 Binary files /dev/null and b/public/animal/horse_male.png differ diff --git a/public/animal/otter_female 1.png b/public/animal/otter_female 1.png new file mode 100644 index 0000000..4036251 Binary files /dev/null and b/public/animal/otter_female 1.png differ diff --git a/public/animal/otter_male 1.png b/public/animal/otter_male 1.png new file mode 100644 index 0000000..9366498 Binary files /dev/null and b/public/animal/otter_male 1.png differ diff --git a/public/animal/rabbit_female 1.png b/public/animal/rabbit_female 1.png new file mode 100644 index 0000000..284ec76 Binary files /dev/null and b/public/animal/rabbit_female 1.png differ diff --git a/public/animal/rabbit_male 1.png b/public/animal/rabbit_male 1.png new file mode 100644 index 0000000..b9b6919 Binary files /dev/null and b/public/animal/rabbit_male 1.png differ diff --git a/public/animal/snake_female.png b/public/animal/snake_female.png new file mode 100644 index 0000000..0e9574f Binary files /dev/null and b/public/animal/snake_female.png differ diff --git a/stores/profile-store.ts b/stores/profile-store.ts new file mode 100644 index 0000000..a09c3af --- /dev/null +++ b/stores/profile-store.ts @@ -0,0 +1,137 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { ProfileData } from "@/lib/types/profile"; + +const STORAGE_KEY = "onboarding-profile-data"; +const LEGACY_STORAGE_KEYS = ["profileBuilder"]; + +const legacyGenderMap: Record = { + 남자: "MALE", + 여자: "FEMALE", +}; + +const legacyContactFrequencyMap: Record< + string, + ProfileData["contactFrequency"] +> = { + 자주: "FREQUENT", + 보통: "NORMAL", + 적음: "RARE", +}; + +interface LegacyProfileBuilderData { + birthYear?: string; + university?: string; + department?: string; + major?: string; + gender?: string; + mbti?: string; + frequency?: string; +} + +const migrateLegacyProfile = ( + legacyProfile: LegacyProfileBuilderData, +): Partial => { + const normalizedMbti = legacyProfile.mbti?.toUpperCase(); + + return { + birthDate: legacyProfile.birthYear + ? `${legacyProfile.birthYear}-01-01` + : undefined, + university: legacyProfile.university, + department: legacyProfile.department, + major: legacyProfile.major, + gender: legacyProfile.gender + ? legacyGenderMap[legacyProfile.gender] + : undefined, + mbti: normalizedMbti as ProfileData["mbti"] | undefined, + contactFrequency: legacyProfile.frequency + ? legacyContactFrequencyMap[legacyProfile.frequency] + : undefined, + }; +}; + +interface ProfileContextType { + profile: ProfileData; + updateProfile: (data: Partial) => void; + clearProfile: () => void; + isReady: boolean; +} + +interface ProfileStoreState extends ProfileContextType { + setReady: (ready: boolean) => void; +} + +export const useProfileStore = create()( + persist( + (set, get) => ({ + profile: {} as ProfileData, + isReady: false, + setReady: (ready) => set({ isReady: ready }), + updateProfile: (data) => { + const updated = { ...get().profile, ...data }; + set({ profile: updated }); + }, + clearProfile: () => { + set({ profile: {} as ProfileData }); + try { + localStorage.removeItem(STORAGE_KEY); + LEGACY_STORAGE_KEYS.forEach((key) => localStorage.removeItem(key)); + } catch (error) { + console.error("Failed to clear profile storage:", error); + } + }, + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ profile: state.profile }), + onRehydrateStorage: () => (state, error) => { + if (error) { + console.error("Failed to hydrate profile store:", error); + } + + if (state && Object.keys(state.profile || {}).length === 0) { + for (const key of LEGACY_STORAGE_KEYS) { + try { + const saved = localStorage.getItem(key); + if (!saved) continue; + + const legacyProfile = JSON.parse( + saved, + ) as LegacyProfileBuilderData; + const migrated = migrateLegacyProfile(legacyProfile); + if ( + Object.values(migrated).some((value) => value !== undefined) + ) { + state.updateProfile(migrated); + } + + localStorage.removeItem(key); + break; + } catch (legacyError) { + console.error( + "Failed to migrate legacy profile storage:", + legacyError, + ); + } + } + } + + state?.setReady(true); + }, + }, + ), +); + +// Backward-compatible alias for existing call sites. +export function useProfile() { + const profile = useProfileStore((state) => state.profile); + const updateProfile = useProfileStore((state) => state.updateProfile); + const clearProfile = useProfileStore((state) => state.clearProfile); + const isReady = useProfileStore((state) => state.isReady); + + return { profile, updateProfile, clearProfile, isReady }; +}