From d75d42283bad16102d0d0bcc74d8e01e611ab534 Mon Sep 17 00:00:00 2001 From: fryzke Date: Fri, 20 Feb 2026 23:35:38 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=83=AD=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20=EB=B0=8F=20=ED=99=9C=EB=8F=99=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 설정 및 활동 내역(좋아요/내가 쓴 글) 탭 분리 - URL 파라미터 연동으로 새로고침 시에도 탭 상태 보존 - Relative Time(상대 시간) 로직 적용 및 카드 클릭 핸들러 추가 - 이벤트 전파 방지를 통한 복사 버튼 기능 독립성 확보 --- app/(afterLogin)/mypage/MypageActivity.tsx | 125 ++++++++++ app/(afterLogin)/mypage/MypageLeftSection.tsx | 99 ++++++++ .../mypage/MypageRightSection.tsx | 221 ++++++++++++++++++ app/(afterLogin)/mypage/layout.tsx | 14 ++ app/(afterLogin)/mypage/page.tsx | 74 +++++- .../(auth)/signup/component/Detail.tsx | 7 +- .../(auth)/signup/component/StatusSelect.tsx | 4 +- .../signup/component/TargetJobsSelect.tsx | 6 +- app/types/type.ts | 15 ++ components/mypage/articleCard.tsx | 76 ++++++ components/mypage/user-experience.tsx | 86 +++++++ components/mypage/user-keyword.tsx | 124 ++++++++++ components/mypage/user-profile.tsx | 95 ++++++++ lib/utils.ts | 7 + package-lock.json | 11 + package.json | 1 + proxy.ts | 2 +- stories/Mypage/UserExperience.stories.tsx | 104 +++++++++ stories/Mypage/UserKeyword.stories.tsx | 106 +++++++++ stories/Mypage/UserProfile.stories.tsx | 58 +++++ 20 files changed, 1228 insertions(+), 7 deletions(-) create mode 100644 app/(afterLogin)/mypage/MypageActivity.tsx create mode 100644 app/(afterLogin)/mypage/MypageLeftSection.tsx create mode 100644 app/(afterLogin)/mypage/MypageRightSection.tsx create mode 100644 app/(afterLogin)/mypage/layout.tsx create mode 100644 components/mypage/articleCard.tsx create mode 100644 components/mypage/user-experience.tsx create mode 100644 components/mypage/user-keyword.tsx create mode 100644 components/mypage/user-profile.tsx create mode 100644 stories/Mypage/UserExperience.stories.tsx create mode 100644 stories/Mypage/UserKeyword.stories.tsx create mode 100644 stories/Mypage/UserProfile.stories.tsx diff --git a/app/(afterLogin)/mypage/MypageActivity.tsx b/app/(afterLogin)/mypage/MypageActivity.tsx new file mode 100644 index 0000000..d16a297 --- /dev/null +++ b/app/(afterLogin)/mypage/MypageActivity.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { LikedPrompt, MyPrompt } from "@/app/types/type"; +import { + LikedArticleCard, + MyArticleCard, +} from "@/components/mypage/articleCard"; +import { toasts } from "@/components/shared/toast"; +import { cn } from "@/lib/utils"; + +export default function MypageActivitySection() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const activeSub = searchParams.get("sub") || "liked"; + + const handleSubTabChange = (sub: "liked" | "posted") => { + const params = new URLSearchParams(searchParams.toString()); + params.set("sub", sub); + router.push(`/mypage?${params.toString()}`, { scroll: false }); + }; + + const handleCopy = async (e: React.MouseEvent, content: string) => { + e.stopPropagation(); // 카드의 onClick이 실행되지 않도록 차단 + try { + await navigator.clipboard.writeText(content); + toasts.success("프롬프트가 클립보드에 복사되었습니다!"); + } catch { + alert("복사에 실패했습니다."); + } + }; + + const handleCardClick = (id: number) => { + router.push(`/prompts/${id}`); + }; + + // 실제 API 데이터 구조에 맞춘 임시 데이터 + // 1. 좋아요 한 프롬프트 데이터 + const mockLikedPrompts = [ + { + promptId: 1024, + title: "좋아요 - 자소서를 위한 GPT 프롬프트", + description: "이 프롬프트는 좋아요 탭에서만 보입니다.", + isLiked: true, + }, + { + promptId: 1025, + title: "좋아요 - 면접 대비 프롬프트", + description: "상세한 설명이 들어가는 영역입니다.", + isLiked: true, + }, + ]; + + // 2. 내가 게시한 프롬프트 데이터 (createdAt 포함) + const mockMyPrompts = [ + { + promptId: 2048, + title: "게시글 - 나만의 비밀 프롬프트", + description: "내가 직접 작성해서 게시한 프롬프트입니다.", + createdAt: "2026-02-18T10:00:00", // n시간 전/일 전 테스트용 + }, + { + promptId: 2049, + title: "게시글 - 효율적인 코딩 프롬프트", + description: "코딩 효율을 200% 높여주는 마법의 문장들.", + createdAt: "2026-01-20T13:50:00", + }, + ]; + const currentList = activeSub === "liked" ? mockLikedPrompts : mockMyPrompts; + + return ( +
+ {/* 서브 탭 메뉴 */} +
+ + +
+ + {/* 리스트 영역 */} +
+ {activeSub === "liked" + ? (mockLikedPrompts as LikedPrompt[]).map((prompt) => ( + handleCopy(e, prompt.description)} + onClick={() => handleCardClick(prompt.promptId)} + /> + )) + : (mockMyPrompts as MyPrompt[]).map((prompt) => ( + handleCardClick(prompt.promptId)} + /> + ))} +
+
+ ); +} diff --git a/app/(afterLogin)/mypage/MypageLeftSection.tsx b/app/(afterLogin)/mypage/MypageLeftSection.tsx new file mode 100644 index 0000000..ca4802c --- /dev/null +++ b/app/(afterLogin)/mypage/MypageLeftSection.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import UserProfile from "@/components/mypage/user-profile"; +import { BaseButton } from "@/components/shared/button"; + +interface UserData { + nickname: string; + email: string; + profileImage: string; + introduction: string; + provider: "KAKAO" | "NAVER"; +} + +export default function MypageLeftSection() { + const router = useRouter(); + const [userData, setUserData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadUserData = async () => { + try { + //나중에 다른것들처럼 mock api생성 후 연결 예정 + // 테스트용 더미 데이터 + setUserData({ + nickname: "개구리", + email: "frog@example.com", + profileImage: "", + introduction: "반갑습니다!", + provider: "KAKAO", + }); + } catch (error) { + console.error("유저 정보 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadUserData(); + }, []); + + const handleLogout = async () => { + if (confirm("로그아웃 하시겠습니까?")) { + try { + //로그아웃 호출 + + alert("로그아웃 되었습니다."); + router.push("/"); //로그아웃되고 어느 페이지로 이동? + router.refresh(); + } catch (error) { + console.error("Logout failed:", error); + } + } + }; + + if (isLoading) return
로딩 중...
; + if (!userData) return
유저 정보가 없습니다.
; + + const handleWithdraw = async () => { + const isConfirmed = confirm( + "정말로 탈퇴하시겠습니까?\n탈퇴 시 모든 데이터가 삭제되며 복구할 수 없습니다." + ); + + if (isConfirmed) { + try { + // 탈퇴 기능 호출 + alert("회원 탈퇴가 완료되었습니다."); + router.push("/"); + } catch (error) { + alert("탈퇴 처리 중 오류가 발생했습니다."); + } + } + }; + return ( +
+ + + + 로그아웃 + + + +
+ ); +} diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx new file mode 100644 index 0000000..ba57694 --- /dev/null +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -0,0 +1,221 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +import { + EDUCATION_OPTIONS, + JobType, + STATE_VALUES, +} from "@/app/(beforeLogin)/(auth)/constant"; +import { StatusSelect } from "@/app/(beforeLogin)/(auth)/signup/component/StatusSelect"; +import { TargetJobsSelect } from "@/app/(beforeLogin)/(auth)/signup/component/TargetJobsSelect"; +import { useSignupStore } from "@/app/store/signUpStore"; +import UserExperience from "@/components/mypage/user-experience"; +import UserKeyword from "@/components/mypage/user-keyword"; +import { BaseButton } from "@/components/shared/button"; +import { BaseCheckBox } from "@/components/shared/checkbox"; +import { BaseInput } from "@/components/shared/inputs"; +import { SelectBox } from "@/components/shared/select-box"; + +export default function MypageRightSection() { + const [otherInput, setOtherInput] = useState(""); + const [experiences, setExperiences] = useState([]); + const [keywords, setKeywords] = useState([]); + + // 개인정보 마케팅 동의 상태 (예시) + const [marketingAgree, setMarketingAgree] = useState(false); + + const { + targetJobs, + setTargetJobs, + currentState, + career, + educationLevel, + updateField, + } = useSignupStore(); + + // 직무 클릭 핸들러 + const handleJobClick = (option: string) => { + const castedOption = option as JobType; + if (targetJobs.includes(castedOption)) { + setTargetJobs(targetJobs.filter((j) => j !== castedOption)); + } else { + setTargetJobs([...targetJobs, castedOption]); + } + }; + + // 섹션별 저장 핸들러 + const handleSaveEmployment = async () => { + const payload = { + currentState: + currentState === STATE_VALUES.OTHER ? otherInput : currentState, + targetJobs, + educationLevel, + career, + }; + console.log("취업/경력 저장:", payload); + alert("취업/경력 정보가 저장되었습니다."); + }; + + const handleSaveProfile = async () => { + const payload = { experiences, keywords }; + console.log("프로필 저장:", payload); + alert("프로필 정보가 저장되었습니다."); + }; + + const handleSavePrivacy = async () => { + console.log("개인정보 설정 저장:", { marketingAgree }); + alert("개인정보 설정이 변경되었습니다."); + }; + + const handleCancel = () => { + if (confirm("변경사항이 저장되지 않을 수 있습니다. 취소하시겠습니까?")) { + window.location.reload(); + } + }; + + return ( +
+ {/* 1. 취업/경력 섹션 */} +
+

{"취업/경력"}

+ { + updateField("currentState", val); + if (val !== STATE_VALUES.OTHER) setOtherInput(""); + }} + onOtherChange={setOtherInput} + /> + +
+
+ + updateField("educationLevel", val)} + selectOptions={EDUCATION_OPTIONS} + /> +
+
+ + { + const val = e.target.value.replace(/[^0-9]/g, ""); + updateField("career", val === "" ? 0 : Number(val)); + }} + /> +
+
+
+ + 취소 + + + 변경사항 저장 + +
+
+ + {/* 2. 자기소개 프로필 섹션 */} +
+
+

{"자기소개 프로필"}

+

+ 자소서 미리보기에 활용될 정보입니다 +

+
+ + +
+ + 취소 + + + 변경사항 저장 + +
+
+ + {/* 3. 개인정보 관리 섹션 */} +
+
+

{"개인정보 관리"}

+

+ 현재 적용 중인 개인정보 수집 및 이용 동의 현황입니다. +

+
+ +
+ setMarketingAgree(!!checked)} + /> + + 보기 + +
+ +
+ + 취소 + + + 변경사항 저장 + +
+
+
+ ); +} diff --git a/app/(afterLogin)/mypage/layout.tsx b/app/(afterLogin)/mypage/layout.tsx new file mode 100644 index 0000000..710287f --- /dev/null +++ b/app/(afterLogin)/mypage/layout.tsx @@ -0,0 +1,14 @@ +import { Header } from "@/components/header/header"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
{children}
+
+ ); +} diff --git a/app/(afterLogin)/mypage/page.tsx b/app/(afterLogin)/mypage/page.tsx index 06752e9..07c5e02 100644 --- a/app/(afterLogin)/mypage/page.tsx +++ b/app/(afterLogin)/mypage/page.tsx @@ -1,5 +1,77 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { BaseButton } from "@/components/shared/button"; +import { PageTitleGroup } from "@/components/shared/page-title-group"; +import { cn } from "@/lib/utils"; + +import MypageActivitySection from "./MypageActivity"; +import MypageLeftSection from "./MypageLeftSection"; +import MypageRightSection from "./MypageRightSection"; + export default function MyPage() { - return
MyPage
; + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL에서 tab 파라미터를 가져옴 (기본값: profile) + const activeTab = + (searchParams.get("tab") as "profile" | "activity") || "profile"; + + // 탭 변경 시 URL 업데이트 + const handleTabChange = (tab: "profile" | "activity") => { + router.push(`/mypage?tab=${tab}`, { scroll: false }); + }; + + return ( +
+
+
+ +
+
+
+ handleTabChange("profile")} + className={cn( + "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", + activeTab === "profile" + ? "bg-frog-600" + : "bg-gray-0 text-gray-700 border border-gray-100" + )} + > + 프로필 설정 + + handleTabChange("activity")} + className={cn( + "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", + activeTab === "activity" + ? "bg-frog-600" + : "bg-gray-0 text-gray-700 border border-gray-100 hover:text-gray-0" + )} + > + 내 활동 + +
+
+
+
+ {/* 왼쪽: 유저 프로필 */} + + + {/* 오른쪽: 정보수정 */} +
+ {activeTab === "profile" ? ( + + ) : ( + + )} +
+
+
+ ); } /** diff --git a/app/(beforeLogin)/(auth)/signup/component/Detail.tsx b/app/(beforeLogin)/(auth)/signup/component/Detail.tsx index a8dc608..06ff7f6 100644 --- a/app/(beforeLogin)/(auth)/signup/component/Detail.tsx +++ b/app/(beforeLogin)/(auth)/signup/component/Detail.tsx @@ -86,6 +86,7 @@ export default function Detail() { />
{ @@ -95,7 +96,11 @@ export default function Detail() { onOtherChange={setOtherInput} /> - +
diff --git a/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx b/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx index 8b9409b..c68bc04 100644 --- a/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx +++ b/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx @@ -3,6 +3,7 @@ import { BaseInput } from "@/components/shared/inputs"; import { STATE_OPTIONS, STATE_VALUES, StateType } from "../../constant"; interface StatusSelectProps { + label: string; value: StateType | ""; otherValue: string; onSelect: (status: StateType) => void; @@ -10,6 +11,7 @@ interface StatusSelectProps { } export const StatusSelect = ({ + label, value, otherValue, onSelect, @@ -17,7 +19,7 @@ export const StatusSelect = ({ }: StatusSelectProps) => { return (
-

현재 상태를 선택해주세요.

+

{label}

{STATE_OPTIONS.map((option) => ( +
+ ))} +
+ + {/* 2. 입력 창 (최대 개수 미달 시에만 노출) */} + {value.length < maxItems && ( +
+ setInputValue(e.target.value.slice(0, 200))} + onKeyDown={(e) => e.key === "Enter" && addItem()} + placeholder={placeholder} + maxLength={200} + viewLength + /> +
+ )} + + {/* 3. 추가하기 버튼 */} + = maxItems} + className="disabled:!bg-gray-100 disabled:!text-gray-400 disabled:!cursor-not-allowed" + > + +

{label} 추가하기

+
+ + ); +} diff --git a/components/mypage/user-keyword.tsx b/components/mypage/user-keyword.tsx new file mode 100644 index 0000000..8d51459 --- /dev/null +++ b/components/mypage/user-keyword.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { X } from "lucide-react"; +import { KeyboardEvent, useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +interface KeywordInputProps { + value: string[]; + label: string; + onChange: (tags: string[]) => void; + placeholder?: string; + maxTags?: number; +} + +export default function UserKeyword({ + value = [], + label, + onChange, + placeholder = "키워드", + maxTags = 5, +}: KeywordInputProps) { + const [inputValue, setInputValue] = useState(""); + const [isFocus, setIsFocus] = useState(false); // 클릭 여부 상태 + const inputRef = useRef(null); + const spanRef = useRef(null); + const [inputWidth, setInputWidth] = useState(60); + + // 글자 길이에 따라 인풋 너비 조절 + useEffect(() => { + if (spanRef.current) { + // 텍스트가 있을 때는 텍스트 길이만큼, 없을 때는 placeholder 길이만큼 + const measuredWidth = spanRef.current.offsetWidth; + setInputWidth(Math.max(60, measuredWidth + 36)); + } + }, [inputValue, isFocus]); + + const addTag = () => { + const trimmed = inputValue.trim(); + if (trimmed && !value.includes(trimmed) && value.length < maxTags) { + onChange([...value, trimmed]); + } + setInputValue(""); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(); + } else if (e.key === "Backspace" && !inputValue && value.length > 0) { + onChange(value.slice(0, -1)); + } + }; + + return ( +
+

{label}

+ +
inputRef.current?.focus()} + > + {/* 1. 확정된 태그 리스트 */} + {value.map((tag, index) => ( + + {tag} + + + ))} + + {/* 2. 입력 중인 태그 */} + {value.length < maxTags && ( +
+ {/* 너비 계산용 (화면엔 안 보임) */} + + {inputValue || (isFocus ? "" : placeholder)} + + + setIsFocus(true)} + onBlur={() => { + addTag(); + setIsFocus(false); + }} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={!isFocus && value.length === 0 ? placeholder : ""} + className={cn( + "w-full bg-transparent outline-none label-medium text-gray-700", + "placeholder:text-gray-400" + )} + /> +
+ )} +
+
+ ); +} diff --git a/components/mypage/user-profile.tsx b/components/mypage/user-profile.tsx new file mode 100644 index 0000000..106f5b7 --- /dev/null +++ b/components/mypage/user-profile.tsx @@ -0,0 +1,95 @@ +import { Mail, User } from "lucide-react"; +import Image from "next/image"; + +import { STATIC_IMAGES } from "@/lib/static-image"; +import { cn } from "@/lib/utils"; + +import { ProfIcon } from "../profile-icon/profile-icon"; + +const CLASSES = { + CONTAINER: + "flex flex-col gap-5 p-6 border border-gray-100 bg-gray-0 rounded-[10px] shadow-md", + AVATAR_WRAPPER: "flex items-center justify-center", + INFO_LIST: "flex flex-col gap-3", + INFO_ITEM: + "flex gap-2 text-gray-500 py-2 px-4 border border-gray-50 rounded-[5px]", + PROVIDER_BADGE: + "flex py-[10px] px-4 gap-2 shadow-md items-center justify-center rounded-[6px]", + PROVIDER_TEXT: "label-medium !font-bold", +} as const; + +interface userProfileProps { + nickname: string; + email: string; + profileImage: string; + provider: "KAKAO" | "NAVER"; + introduction: string; +} + +export default function UserProfile({ + nickname, + profileImage, + email, + provider, + introduction, +}: userProfileProps) { + const getTheme = () => { + switch (provider) { + case "NAVER": + return { + bg: "bg-[#01C73C]", + label: "네이버", + image: STATIC_IMAGES.naver, + }; + case "KAKAO": + default: + return { + bg: "bg-[#F9DB00]", + label: "카카오", + image: STATIC_IMAGES.kakao, + }; + } + }; + const theme = getTheme(); + + return ( +
+ {/* 프로필 이미지 */} +
+ +
+ + {/* 정보 리스트 */} +
+
+ +

{nickname}

+
+
+ +

{email}

+
+
+

{introduction || "등록된 소개글이 없습니다."}

+
+
+ + {/* 계정 연결 정보 배지 */} +
+ {theme.label} +

{theme.label} 계정 연결됨

+
+
+ ); +} diff --git a/lib/utils.ts b/lib/utils.ts index 883f01d..5c1a2a3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,6 @@ import { type ClassValue, clsx } from "clsx"; +import { formatDistanceToNow, parseISO } from "date-fns"; +import { ko } from "date-fns/locale"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { @@ -14,3 +16,8 @@ export function stringToColor(str: string) { const h = Math.abs(hash) % 360; return `hsl(${h}, 70%, 60%)`; } + +export function formatRelativeDate(dateString: string) { + const date = parseISO(dateString); + return formatDistanceToNow(date, { addSuffix: true, locale: ko }); +} diff --git a/package-lock.json b/package-lock.json index 3cafa46..3bbdcc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/dompurify": "^3.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.3.1", "jwt-decode": "^4.0.0", "lucide-react": "^0.562.0", @@ -9176,6 +9177,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 2954aa9..be9f1fa 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/dompurify": "^3.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.3.1", "jwt-decode": "^4.0.0", "lucide-react": "^0.562.0", diff --git a/proxy.ts b/proxy.ts index ba19f0f..819348f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,7 +10,7 @@ export default auth((req) => { const isSignIn = !!session; const isNewUser = session?.isNewUser; - const protectedRoutes = [ROUTES.mypage.ROOT]; + const protectedRoutes = ["ROUTES.mypage.ROOT"]; const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route) diff --git a/stories/Mypage/UserExperience.stories.tsx b/stories/Mypage/UserExperience.stories.tsx new file mode 100644 index 0000000..b897809 --- /dev/null +++ b/stories/Mypage/UserExperience.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; + +import UserExperience from "@/components/mypage/user-experience"; + +const meta: Meta = { + title: "Components/Mypage/UserExperience", + component: UserExperience, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + label: { + control: { type: "text" }, + description: "라벨", + }, + placeholder: { + control: { type: "text" }, + description: "placeholder 텍스트", + }, + maxItems: { + control: { type: "number" }, + description: "입력 가능한 최대 아이템 개수", + }, + value: { + control: "object", + description: "현재 입력된 경험 리스트", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * 1. 기본 상태 (상태 변화 확인용) + * 실제 입력과 삭제가 동작하도록 useState를 연결한 래퍼입니다. + */ +export const Interactive: Story = { + render: (args) => { + const [value, setValue] = useState(args.value || []); + return ( +
+ +
+ ); + }, + args: { + value: [], + maxItems: 3, + }, +}; + +/** + * 2. 초기 데이터가 있는 상태 + */ +export const DefaultWithData: Story = { + args: { + value: ["Google Software Engineer 인턴", "교내 해커톤 대상 수상"], + maxItems: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 3. 최대 개수에 도달한 상태 (입력창이 사라짐) + */ +export const MaxReached: Story = { + args: { + value: ["경험 1", "경험 2", "경험 3"], + maxItems: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 4. 많은 개수를 허용할 때 + */ +export const ManyItemsAllowed: Story = { + args: { + value: ["리액트 공부", "타입스크립트 공부"], + maxItems: 10, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/stories/Mypage/UserKeyword.stories.tsx b/stories/Mypage/UserKeyword.stories.tsx new file mode 100644 index 0000000..1381255 --- /dev/null +++ b/stories/Mypage/UserKeyword.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; + +import UserKeyword from "@/components/mypage/user-keyword"; + +const meta: Meta = { + title: "Components/Mypage/UserKeyword", + component: UserKeyword, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + label: { + control: { type: "text" }, + description: "라벨 텍스트", + }, + maxTags: { + control: { type: "number" }, + description: "입력 가능한 최대 태그 개수", + }, + value: { + control: "object", + description: "현재 입력된 키워드 배열", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * 1. 인터렉티브 상태 (실제 동작 확인) + */ +export const Interactive: Story = { + render: (args) => { + const [value, setValue] = useState(args.value || []); + return ( +
+ +
+ ); + }, + args: { + value: [], + maxTags: 5, + placeholder: "키워드 입력 후 Enter", + }, +}; + +/** + * 2. 태그가 이미 여러 개 있는 상태 + */ +export const WithTags: Story = { + args: { + value: ["React", "Next.js", "TypeScript", "TailwindCSS"], + maxTags: 5, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 3. 최대 개수에 도달한 상태 (Disabled) + * 인풋이 비활성화됩니다. + */ +export const MaxTagsReached: Story = { + args: { + value: ["열정맨", "성실함", "소통왕", "문제해결사", "긍정주의자"], + maxTags: 5, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 4. 긴 텍스트 태그 테스트 + * 태그가 길어질 때 줄바꿈(flex-wrap)이 잘 일어나는지 확인합니다. + */ +export const LongTagTest: Story = { + args: { + value: [ + "아주아주긴키워드테스트입니다", + "이것또한매우매우매우매우긴키워드", + "짧은키워드", + ], + maxTags: 8, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/stories/Mypage/UserProfile.stories.tsx b/stories/Mypage/UserProfile.stories.tsx new file mode 100644 index 0000000..87181e9 --- /dev/null +++ b/stories/Mypage/UserProfile.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; + +import UserProfile from "@/components/mypage/user-profile"; + +const meta: Meta = { + title: "Components/Mypage/UserProfile", + component: UserProfile, + tags: ["autodocs"], + argTypes: { + provider: { + control: "radio", + options: ["KAKAO", "NAVER"], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** 1. 카카오 로그인 사용자 케이스 */ +export const KakaoProfile: Story = { + args: { + nickname: "라이언", + email: "ryan@kakao.com", + provider: "KAKAO", + introduction: "안녕하세요, 카카오에서 온 라이언입니다. 즐거운 하루 되세요!", + }, +}; + +/** 2. 네이버 로그인 사용자 케이스 */ +export const NaverProfile: Story = { + args: { + nickname: "그린팩토리", + email: "naver_user@naver.com", + provider: "NAVER", + introduction: "네이버 프로필입니다. 초록색 배경이 특징이에요.", + }, +}; + +/** 3. 긴 소개글 테스트 케이스 */ +export const LongIntroduction: Story = { + args: { + nickname: "글자수테스트", + email: "test@example.com", + provider: "KAKAO", + introduction: + "소개글이 아주 길어질 경우 레이아웃이 어떻게 변하는지 확인하기 위한 케이스입니다. ".repeat( + 3 + ), + }, +}; From 176e1fdb69263534fbe7d873f7d602cefc74b36c Mon Sep 17 00:00:00 2001 From: fryzke Date: Fri, 20 Feb 2026 23:54:27 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20UserProfile=20=EB=82=B4=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95=20(icon=20-?= =?UTF-8?q?>=20image)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/mypage/user-profile.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/mypage/user-profile.tsx b/components/mypage/user-profile.tsx index 106f5b7..fb92fec 100644 --- a/components/mypage/user-profile.tsx +++ b/components/mypage/user-profile.tsx @@ -83,10 +83,10 @@ export default function UserProfile({ {/* 계정 연결 정보 배지 */}
{theme.label}

{theme.label} 계정 연결됨

From 5887045977da4a7ff628666354fec7752b9ca32c Mon Sep 17 00:00:00 2001 From: fryzke Date: Sat, 21 Feb 2026 00:01:24 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20proxy=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=20=EC=B2=98=EB=A6=AC=ED=95=9C=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0,=20=EA=B2=BD=ED=97=98,=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80(?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=EA=B0=AF=EC=88=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/mypage/user-experience.tsx | 2 +- components/mypage/user-keyword.tsx | 2 +- proxy.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/mypage/user-experience.tsx b/components/mypage/user-experience.tsx index d6c35be..b37bc5d 100644 --- a/components/mypage/user-experience.tsx +++ b/components/mypage/user-experience.tsx @@ -19,7 +19,7 @@ export default function UserExperience({ onChange, label, placeholder, - maxItems = 3, + maxItems = 3, //TODO: 최대 몇개까지인지 (추후 정해지는대로 수정) }: userExperienceProps) { const [inputValue, setInputValue] = useState(""); diff --git a/components/mypage/user-keyword.tsx b/components/mypage/user-keyword.tsx index 8d51459..47cff5b 100644 --- a/components/mypage/user-keyword.tsx +++ b/components/mypage/user-keyword.tsx @@ -18,7 +18,7 @@ export default function UserKeyword({ label, onChange, placeholder = "키워드", - maxTags = 5, + maxTags = 5, //TODO: 최대 몇개까지인지 (추후 정해지는대로 수정) }: KeywordInputProps) { const [inputValue, setInputValue] = useState(""); const [isFocus, setIsFocus] = useState(false); // 클릭 여부 상태 diff --git a/proxy.ts b/proxy.ts index 819348f..ba19f0f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,7 +10,7 @@ export default auth((req) => { const isSignIn = !!session; const isNewUser = session?.isNewUser; - const protectedRoutes = ["ROUTES.mypage.ROOT"]; + const protectedRoutes = [ROUTES.mypage.ROOT]; const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route) From c3d3d41d9995f51532a4b400d382aadd68aef0d7 Mon Sep 17 00:00:00 2001 From: fryzke Date: Sat, 21 Feb 2026 23:20:57 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83,=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20API=20=EB=B0=8F=20Mut?= =?UTF-8?q?ation=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(afterLogin)/mypage/MypageActivity.tsx | 92 ++++++------ app/(afterLogin)/mypage/MypageLeftSection.tsx | 101 ++++++------- .../mypage/MypageRightSection.tsx | 138 ++++++++++++------ app/(afterLogin)/mypage/MypageTabButtons.tsx | 41 ++++++ app/(afterLogin)/mypage/page.tsx | 65 ++------- app/(beforeLogin)/(auth)/constant.ts | 55 ++++--- hooks/use-mypage.ts | 77 ++++++++++ mocks/handlers/auth-handlers.ts | 29 ++++ mocks/handlers/handlers.ts | 2 + mocks/handlers/mypage-handlers.ts | 134 +++++++++++++++++ mocks/handlers/onboarding-handlers.ts | 2 +- package-lock.json | 71 +++++++-- package.json | 2 + proxy.ts | 2 +- queries/api/auth.ts | 23 +++ queries/api/mypage.ts | 66 +++++++++ queries/api/types.ts | 64 ++++++++ 17 files changed, 736 insertions(+), 228 deletions(-) create mode 100644 app/(afterLogin)/mypage/MypageTabButtons.tsx create mode 100644 hooks/use-mypage.ts create mode 100644 mocks/handlers/mypage-handlers.ts create mode 100644 queries/api/mypage.ts create mode 100644 queries/api/types.ts diff --git a/app/(afterLogin)/mypage/MypageActivity.tsx b/app/(afterLogin)/mypage/MypageActivity.tsx index d16a297..b6896ca 100644 --- a/app/(afterLogin)/mypage/MypageActivity.tsx +++ b/app/(afterLogin)/mypage/MypageActivity.tsx @@ -2,28 +2,42 @@ import { useRouter, useSearchParams } from "next/navigation"; -import { LikedPrompt, MyPrompt } from "@/app/types/type"; import { LikedArticleCard, MyArticleCard, } from "@/components/mypage/articleCard"; +import { PaginationButton } from "@/components/pagination-button/pagination-button"; import { toasts } from "@/components/shared/toast"; +import { useLikedPrompts, useUserPrompts } from "@/hooks/use-mypage"; import { cn } from "@/lib/utils"; export default function MypageActivitySection() { const router = useRouter(); const searchParams = useSearchParams(); - const activeSub = searchParams.get("sub") || "liked"; + // 1. 현재 탭 및 페이지 상태 관리 + const activeSub = (searchParams.get("sub") as "liked" | "posted") || "liked"; + const currentPage = Number(searchParams.get("page")) || 0; + const userId = 1; // 실제로는 인증 정보나 프로필 훅에서 가져온 ID 사용 + + const { data: likedData, isLoading: isLikedLoading } = useLikedPrompts( + userId, + currentPage + ); + const { data: postedData, isLoading: isPostedLoading } = useUserPrompts({ + userId, + page: currentPage, + }); const handleSubTabChange = (sub: "liked" | "posted") => { const params = new URLSearchParams(searchParams.toString()); params.set("sub", sub); + params.set("page", "0"); router.push(`/mypage?${params.toString()}`, { scroll: false }); }; const handleCopy = async (e: React.MouseEvent, content: string) => { - e.stopPropagation(); // 카드의 onClick이 실행되지 않도록 차단 + e.stopPropagation(); try { await navigator.clipboard.writeText(content); toasts.success("프롬프트가 클립보드에 복사되었습니다!"); @@ -31,44 +45,19 @@ export default function MypageActivitySection() { alert("복사에 실패했습니다."); } }; + const handlePageChange = (page: number) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("page", page.toString()); + router.push(`/mypage?${params.toString()}`, { scroll: false }); + }; const handleCardClick = (id: number) => { router.push(`/prompts/${id}`); }; - // 실제 API 데이터 구조에 맞춘 임시 데이터 - // 1. 좋아요 한 프롬프트 데이터 - const mockLikedPrompts = [ - { - promptId: 1024, - title: "좋아요 - 자소서를 위한 GPT 프롬프트", - description: "이 프롬프트는 좋아요 탭에서만 보입니다.", - isLiked: true, - }, - { - promptId: 1025, - title: "좋아요 - 면접 대비 프롬프트", - description: "상세한 설명이 들어가는 영역입니다.", - isLiked: true, - }, - ]; - - // 2. 내가 게시한 프롬프트 데이터 (createdAt 포함) - const mockMyPrompts = [ - { - promptId: 2048, - title: "게시글 - 나만의 비밀 프롬프트", - description: "내가 직접 작성해서 게시한 프롬프트입니다.", - createdAt: "2026-02-18T10:00:00", // n시간 전/일 전 테스트용 - }, - { - promptId: 2049, - title: "게시글 - 효율적인 코딩 프롬프트", - description: "코딩 효율을 200% 높여주는 마법의 문장들.", - createdAt: "2026-01-20T13:50:00", - }, - ]; - const currentList = activeSub === "liked" ? mockLikedPrompts : mockMyPrompts; + // 현재 활성화된 데이터와 로딩 상태 결정 + const currentData = activeSub === "liked" ? likedData : postedData; + const isLoading = activeSub === "liked" ? isLikedLoading : isPostedLoading; return (
@@ -100,8 +89,17 @@ export default function MypageActivitySection() { {/* 리스트 영역 */}
- {activeSub === "liked" - ? (mockLikedPrompts as LikedPrompt[]).map((prompt) => ( + {isLoading ? ( +
로딩 중...
+ ) : currentData?.content.length === 0 ? ( +
+ {activeSub === "liked" + ? "좋아요한 프롬프트가 없습니다." + : "게시한 프롬프트가 없습니다."} +
+ ) : ( + currentData?.content.map((prompt) => + activeSub === "liked" ? ( handleCopy(e, prompt.description)} onClick={() => handleCardClick(prompt.promptId)} /> - )) - : (mockMyPrompts as MyPrompt[]).map((prompt) => ( + ) : ( handleCardClick(prompt.promptId)} /> - ))} + ) + ) + )}
+ + {/* 단순 페이지네이션 (필요 시 추가) */} + {!isLoading && (currentData?.pageInfo.totalPages ?? 0) > 1 && ( +
+ +
+ )}
); } diff --git a/app/(afterLogin)/mypage/MypageLeftSection.tsx b/app/(afterLogin)/mypage/MypageLeftSection.tsx index ca4802c..19b6b79 100644 --- a/app/(afterLogin)/mypage/MypageLeftSection.tsx +++ b/app/(afterLogin)/mypage/MypageLeftSection.tsx @@ -1,86 +1,78 @@ "use client"; - +import { useQueryClient } from "@tanstack/react-query"; +import Cookies from "js-cookie"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import UserProfile from "@/components/mypage/user-profile"; import { BaseButton } from "@/components/shared/button"; - -interface UserData { - nickname: string; - email: string; - profileImage: string; - introduction: string; - provider: "KAKAO" | "NAVER"; -} +import { toasts } from "@/components/shared/toast"; +import { useUserProfile } from "@/hooks/use-mypage"; +import { deleteUserAccount, postLogout } from "@/queries/api/auth"; export default function MypageLeftSection() { const router = useRouter(); - const [userData, setUserData] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const queryClient = useQueryClient(); - useEffect(() => { - const loadUserData = async () => { - try { - //나중에 다른것들처럼 mock api생성 후 연결 예정 - // 테스트용 더미 데이터 - setUserData({ - nickname: "개구리", - email: "frog@example.com", - profileImage: "", - introduction: "반갑습니다!", - provider: "KAKAO", - }); - } catch (error) { - console.error("유저 정보 로드 실패:", error); - } finally { - setIsLoading(false); - } - }; - - loadUserData(); - }, []); + const { data: userData, isLoading } = useUserProfile(); const handleLogout = async () => { if (confirm("로그아웃 하시겠습니까?")) { try { - //로그아웃 호출 + await postLogout(); + + Cookies.remove("accessToken"); + Cookies.remove("refreshToken"); - alert("로그아웃 되었습니다."); - router.push("/"); //로그아웃되고 어느 페이지로 이동? + queryClient.clear(); + toasts.success("로그아웃 되었습니다."); + router.push("/"); router.refresh(); } catch (error) { + Cookies.remove("accessToken"); console.error("Logout failed:", error); } } }; - if (isLoading) return
로딩 중...
; - if (!userData) return
유저 정보가 없습니다.
; - const handleWithdraw = async () => { const isConfirmed = confirm( "정말로 탈퇴하시겠습니까?\n탈퇴 시 모든 데이터가 삭제되며 복구할 수 없습니다." ); - if (isConfirmed) { - try { - // 탈퇴 기능 호출 - alert("회원 탈퇴가 완료되었습니다."); - router.push("/"); - } catch (error) { - alert("탈퇴 처리 중 오류가 발생했습니다."); - } + if (!isConfirmed || !userData) return; + + try { + await deleteUserAccount(userData.basicInfo.uid, { + reason: "서비스 이용 불편", + }); + + Cookies.remove("accessToken"); + queryClient.clear(); + + toasts.success("회원 탈퇴가 완료되었습니다. 이용해 주셔서 감사합니다."); + router.push("/"); + router.refresh(); + } catch (error) { + console.error("Withdrawal failed:", error); + //toasts.error("탈퇴 처리 중 오류가 발생했습니다. 고객센터에 문의해주세요."); } }; + + // 로딩 및 에러 처리 + if (isLoading) return
로딩 중...
; + if (!userData) + return
유저 정보가 없습니다.
; + + const { basicInfo } = userData; + return (
-
diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx index ba57694..c34ac9e 100644 --- a/app/(afterLogin)/mypage/MypageRightSection.tsx +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -1,12 +1,17 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { EDUCATION_OPTIONS, + JOB_MAP, JobType, + REVERSE_JOB_MAP, + REVERSE_STATE_MAP, + STATE_MAP, STATE_VALUES, + StateType, } from "@/app/(beforeLogin)/(auth)/constant"; import { StatusSelect } from "@/app/(beforeLogin)/(auth)/signup/component/StatusSelect"; import { TargetJobsSelect } from "@/app/(beforeLogin)/(auth)/signup/component/TargetJobsSelect"; @@ -17,13 +22,16 @@ import { BaseButton } from "@/components/shared/button"; import { BaseCheckBox } from "@/components/shared/checkbox"; import { BaseInput } from "@/components/shared/inputs"; import { SelectBox } from "@/components/shared/select-box"; +import { useUpdateProfile, useUserProfile } from "@/hooks/use-mypage"; +import { UpdateProfileRequest } from "@/queries/api/mypage"; export default function MypageRightSection() { + const { data: profile, isLoading } = useUserProfile(); + const { mutate: updateProfile } = useUpdateProfile(); + const [otherInput, setOtherInput] = useState(""); const [experiences, setExperiences] = useState([]); const [keywords, setKeywords] = useState([]); - - // 개인정보 마케팅 동의 상태 (예시) const [marketingAgree, setMarketingAgree] = useState(false); const { @@ -35,7 +43,41 @@ export default function MypageRightSection() { updateField, } = useSignupStore(); - // 직무 클릭 핸들러 + // 1. 서버 데이터 로드 시 초기화 및 타입 싱크 + useEffect(() => { + if (profile) { + const { careerInfo, selfIntro } = profile; + + // 직무 변환 (영문 코드 배열 -> 한글 라벨 배열) + const jobsInKorean = careerInfo.targetJob.map( + (code) => JOB_MAP.get(code) || code + ); + setTargetJobs(jobsInKorean as JobType[]); + + // 상태 변환 (배열의 첫 번째 요소 추출 -> 한글 라벨) + const serverStateKey = careerInfo.currentStatus[0]; + const stateInKorean = STATE_MAP.get(serverStateKey) || ""; + + // Zustand 스토어 타입에 맞게 단언하여 업데이트 + updateField("currentState", stateInKorean as "" | StateType); + + updateField("educationLevel", careerInfo.education); + const serverCareer = careerInfo.careerYear; + const careerNumber = serverCareer === "신입" ? 0 : Number(serverCareer); + updateField("career", careerNumber); + setExperiences(selfIntro.experiences || []); + setKeywords(selfIntro.keywords || []); + } + }, [profile, setTargetJobs, updateField]); + + if (isLoading) { + return ( +
+ 정보를 불러오는 중... +
+ ); + } + const handleJobClick = (option: string) => { const castedOption = option as JobType; if (targetJobs.includes(castedOption)) { @@ -45,28 +87,36 @@ export default function MypageRightSection() { } }; - // 섹션별 저장 핸들러 - const handleSaveEmployment = async () => { - const payload = { - currentState: - currentState === STATE_VALUES.OTHER ? otherInput : currentState, - targetJobs, - educationLevel, - career, - }; - console.log("취업/경력 저장:", payload); - alert("취업/경력 정보가 저장되었습니다."); - }; + // 2. 저장 핸들러 + const handleGlobalSave = () => { + if (!profile) return; - const handleSaveProfile = async () => { - const payload = { experiences, keywords }; - console.log("프로필 저장:", payload); - alert("프로필 정보가 저장되었습니다."); - }; + const targetJobCodes = targetJobs.map( + (label) => REVERSE_JOB_MAP.get(label) || label + ); + + const currentStateCode = + REVERSE_STATE_MAP.get(currentState) || currentState; + + const payload: UpdateProfileRequest = { + basicInfo: { + nickname: profile.basicInfo.nickname, + introduction: profile.basicInfo.introduction || "", + }, + careerInfo: { + currentStatus: currentStateCode, + targetJob: targetJobCodes, + careerYear: career, + education: educationLevel, + major: profile.careerInfo.major || "", + }, + selfIntro: { + experiences, + keywords, + }, + }; - const handleSavePrivacy = async () => { - console.log("개인정보 설정 저장:", { marketingAgree }); - alert("개인정보 설정이 변경되었습니다."); + updateProfile(payload); }; const handleCancel = () => { @@ -78,10 +128,10 @@ export default function MypageRightSection() { return (
{/* 1. 취업/경력 섹션 */} -
-

{"취업/경력"}

+
+

취업/경력

{ @@ -91,7 +141,7 @@ export default function MypageRightSection() { onOtherChange={setOtherInput} /> @@ -108,12 +158,11 @@ export default function MypageRightSection() {
{ const val = e.target.value.replace(/[^0-9]/g, ""); updateField("career", val === "" ? 0 : Number(val)); @@ -130,18 +179,18 @@ export default function MypageRightSection() { 취소 변경사항 저장
-
+ {/* 2. 자기소개 프로필 섹션 */} -
+
-

{"자기소개 프로필"}

+

자기소개 프로필

자소서 미리보기에 활용될 정보입니다

@@ -149,13 +198,12 @@ export default function MypageRightSection() {
@@ -167,23 +215,22 @@ export default function MypageRightSection() { 취소 변경사항 저장
-
+
{/* 3. 개인정보 관리 섹션 */} -
+
-

{"개인정보 관리"}

+

개인정보 관리

현재 적용 중인 개인정보 수집 및 이용 동의 현황입니다.

-
setMarketingAgree(!!checked)} /> 보기
-
변경사항 저장
-
+
); } diff --git a/app/(afterLogin)/mypage/MypageTabButtons.tsx b/app/(afterLogin)/mypage/MypageTabButtons.tsx new file mode 100644 index 0000000..38faeb2 --- /dev/null +++ b/app/(afterLogin)/mypage/MypageTabButtons.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { BaseButton } from "@/components/shared/button"; +import { cn } from "@/lib/utils"; + +export default function MypageTabButtons({ activeTab }: { activeTab: string }) { + const router = useRouter(); + + const handleTabChange = (tab: "profile" | "activity") => { + router.push(`/mypage?tab=${tab}`, { scroll: false }); + }; + + return ( +
+ handleTabChange("profile")} + className={cn( + "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", + activeTab === "profile" + ? "bg-frog-600 text-white" + : "bg-gray-0 text-gray-700 border border-gray-100" + )} + > + 프로필 설정 + + handleTabChange("activity")} + className={cn( + "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", + activeTab === "activity" + ? "bg-frog-600 text-white" + : "bg-gray-0 text-gray-700 border border-gray-100" + )} + > + 내 활동 + +
+ ); +} diff --git a/app/(afterLogin)/mypage/page.tsx b/app/(afterLogin)/mypage/page.tsx index 07c5e02..3741ca6 100644 --- a/app/(afterLogin)/mypage/page.tsx +++ b/app/(afterLogin)/mypage/page.tsx @@ -1,26 +1,17 @@ -"use client"; -import { useRouter, useSearchParams } from "next/navigation"; - -import { BaseButton } from "@/components/shared/button"; import { PageTitleGroup } from "@/components/shared/page-title-group"; -import { cn } from "@/lib/utils"; import MypageActivitySection from "./MypageActivity"; import MypageLeftSection from "./MypageLeftSection"; import MypageRightSection from "./MypageRightSection"; +import MypageTabButtons from "./MypageTabButtons"; -export default function MyPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - - // URL에서 tab 파라미터를 가져옴 (기본값: profile) - const activeTab = - (searchParams.get("tab") as "profile" | "activity") || "profile"; +interface Props { + searchParams: Promise<{ tab?: string }>; +} - // 탭 변경 시 URL 업데이트 - const handleTabChange = (tab: "profile" | "activity") => { - router.push(`/mypage?tab=${tab}`, { scroll: false }); - }; +export default async function MyPage({ searchParams }: Props) { + const { tab } = await searchParams; + const activeTab = (tab as "profile" | "activity") || "profile"; return (
@@ -28,40 +19,20 @@ export default function MyPage() {
+ + {/* 탭 버튼 영역 */}
-
- handleTabChange("profile")} - className={cn( - "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", - activeTab === "profile" - ? "bg-frog-600" - : "bg-gray-0 text-gray-700 border border-gray-100" - )} - > - 프로필 설정 - - handleTabChange("activity")} - className={cn( - "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", - activeTab === "activity" - ? "bg-frog-600" - : "bg-gray-0 text-gray-700 border border-gray-100 hover:text-gray-0" - )} - > - 내 활동 - -
+
+
{/* 왼쪽: 유저 프로필 */} - {/* 오른쪽: 정보수정 */} + {/* 오른쪽: 탭 내용 */}
{activeTab === "profile" ? ( @@ -73,15 +44,3 @@ export default function MyPage() {
); } - -/** - * 마이페이지 search params - * - * - tab - * profile - * tab - * - * - sub - * liked - * posted - */ diff --git a/app/(beforeLogin)/(auth)/constant.ts b/app/(beforeLogin)/(auth)/constant.ts index eda2c7a..2f0f32d 100644 --- a/app/(beforeLogin)/(auth)/constant.ts +++ b/app/(beforeLogin)/(auth)/constant.ts @@ -5,35 +5,50 @@ export enum AuthProvider { NAVER = "naver", } -export const STATE_OPTIONS = [ - "재학중", - "취업준비", - "이직준비", - "기타", -] as const; -export const JOB_OPTIONS = [ - "기획", - "마케팅", - "디자인", - "개발", - "영업", - "기타", -] as const; - export const STATE_VALUES = { - IN_SCHOOL: "재학중", - JOB_SEEKING: "취업준비", + STUDENT: "재학중", + JOB_SEEKER: "취업준비", CHANGING_JOB: "이직준비", OTHER: "기타", } as const; -export type StateType = (typeof STATE_OPTIONS)[number]; -export type JobType = (typeof JOB_OPTIONS)[number]; +export const JOB_DATA = { + PLANNING: "기획", + MARKETING: "마케팅", + DESIGN: "디자인", + DEVELOPMENT: "개발", + SALES: "영업", + PM: "PM", + OTHER: "기타", +} as const; + +export const STATE_MAP = new Map( + Object.entries(STATE_VALUES) as [string, string][] +); + +export const JOB_MAP = new Map( + Object.entries(JOB_DATA) as [string, string][] +); + +export const REVERSE_STATE_MAP = new Map( + (Object.entries(STATE_VALUES) as [string, string][]).map(([k, v]) => [v, k]) +); + +export const REVERSE_JOB_MAP = new Map( + (Object.entries(JOB_DATA) as [string, string][]).map(([k, v]) => [v, k]) +); + +export const STATE_OPTIONS = Object.values(STATE_VALUES); +export const JOB_OPTIONS = Object.values(JOB_DATA); + +export type StateType = (typeof STATE_VALUES)[keyof typeof STATE_VALUES]; +export type JobType = (typeof JOB_DATA)[keyof typeof JOB_DATA]; +export type JobTypeCode = keyof typeof JOB_DATA; export const EDUCATION_OPTIONS: SelectOption[] = [ { label: "고등학교 졸업", value: "HIGH_SCHOOL" }, { label: "전문대 졸업", value: "ASSOCIATE" }, - { label: "대학교 졸업", value: "BACHELOR_4" }, + { label: "대학교 졸업", value: "BACHELOR" }, { label: "석사 졸업", value: "MASTER" }, { label: "박사 졸업", value: "DOCTOR" }, ]; diff --git a/hooks/use-mypage.ts b/hooks/use-mypage.ts new file mode 100644 index 0000000..aa4cc15 --- /dev/null +++ b/hooks/use-mypage.ts @@ -0,0 +1,77 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { toasts } from "@/components/shared/toast"; +import { ApiError } from "@/lib/fetcher"; +import { + getLikedPrompts, + getUserProfile, + getUserPrompts, + UpdateProfileRequest, + updateUserProfile, +} from "@/queries/api/mypage"; + +export const useUserProfile = () => { + return useQuery({ + queryKey: ["user", "profile"], + queryFn: () => getUserProfile(), + select: (response) => response.data, + throwOnError: (error) => error instanceof ApiError && error.status >= 500, + }); +}; + +interface UseUserPromptsProps { + userId: string | number; + page: number; + size?: number; +} + +export const useUserPrompts = ({ + userId, + page, + size = 4, +}: UseUserPromptsProps) => { + return useQuery({ + queryKey: ["user", "prompts", userId, { page, size }], + queryFn: () => getUserPrompts({ userId, page, size }), + placeholderData: (previousData) => previousData, + select: (response) => response.data, + }); +}; +export const useLikedPrompts = (userId: string | number, page: number) => { + return useQuery({ + queryKey: ["user", "likes", userId, { page }], + queryFn: () => getLikedPrompts({ userId, page }), + placeholderData: (previousData) => previousData, + select: (res) => res.data, + }); +}; + +export const useUpdateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: UpdateProfileRequest) => updateUserProfile(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["user", "profile"] }); + toasts.success("프로필 정보가 성공적으로 저장되었습니다."); + }, + onError: (error: Error) => { + if (error instanceof ApiError) { + // 401 권한 없음 (세션 만료) 처리 + if (error.status === 401) { + //toasts.error("세션이 만료되었습니다. 다시 로그인해주세요."); + // router.push('/login'); // 필요시 이동 + return; + } + // 그 외 서버가 보내준 메시지 표시 + //toasts.error(error.message || "오류가 발생했습니다."); + } else { + // 네트워크 단절 등 예상치 못한 일반 에러 + //toasts.error("네트워크 상태를 확인해주세요."); + console.error("네트워크 상태를 확인해주세요."); + } + + console.error(`[${error.name}]`, error.message); + }, + }); +}; diff --git a/mocks/handlers/auth-handlers.ts b/mocks/handlers/auth-handlers.ts index 5e581a5..a8a6235 100644 --- a/mocks/handlers/auth-handlers.ts +++ b/mocks/handlers/auth-handlers.ts @@ -49,4 +49,33 @@ export const authHandlers = [ }, }); }), + http.post(`${BASE_URL}/auth/logout`, () => { + return HttpResponse.json( + { + success: true, + code: "200", + data: {}, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); + }), + http.delete(`${BASE_URL}/users/:uid`, async ({ params }) => { + const { uid } = params; + + return HttpResponse.json( + { + success: true, + code: "200", + message: "회원 탈퇴 및 소셜 연동 해제가 정상적으로 처리되었습니다.", + data: { + uid: uid, + unlinkedProvider: "KAKAO", + terminatedAt: new Date().toISOString(), + }, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); + }), ]; diff --git a/mocks/handlers/handlers.ts b/mocks/handlers/handlers.ts index 7c5e51a..a063343 100644 --- a/mocks/handlers/handlers.ts +++ b/mocks/handlers/handlers.ts @@ -1,5 +1,6 @@ import { authHandlers } from "./auth-handlers"; import { mswHandlers } from "./msw-test"; +import { mypageHandlers } from "./mypage-handlers"; import { nicknameHandlers } from "./nickname-handlers"; import { onboardingHandlers } from "./onboarding-handlers"; import { promptListHandlers } from "./prompt-list-handlers"; @@ -12,4 +13,5 @@ export const handlers = [ ...authHandlers, ...nicknameHandlers, ...promptListHandlers, + ...mypageHandlers, ]; diff --git a/mocks/handlers/mypage-handlers.ts b/mocks/handlers/mypage-handlers.ts new file mode 100644 index 0000000..2c700c2 --- /dev/null +++ b/mocks/handlers/mypage-handlers.ts @@ -0,0 +1,134 @@ +import { http, HttpResponse } from "msw"; + +import { UserProfileResponse, UserPromptsResponse } from "@/queries/api/types"; + +const BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_API_URL || "http://localhost:8080/api/v1"; + +export const mypageHandlers = [ + http.get(`${BASE_URL}/users/me/profile`, ({ request }) => { + const authHeader = request.headers.get("Authorization"); + + if (!authHeader) { + return new HttpResponse(null, { status: 401 }); + } + + return HttpResponse.json({ + success: true, + status: 200, + data: { + basicInfo: { + uid: "user_12345", + nickname: "prog_user", + email: "user@example.com", + provider: "KAKAO", + introduction: "안녕하세요, 기획자를 꿈꾸는 유저입니다.", + }, + careerInfo: { + currentStatus: ["JOB_SEEKER", "STUDENT"], + targetJob: ["PLANNING", "PM"], + careerYear: "신입", + education: "BACHELOR", + major: "산업디자인학과", + }, + selfIntro: { + experiences: [], + keywords: ["서비스 기획", "데이터 분석", "커뮤니케이션"], + }, + }, + }); + }), + http.get(`${BASE_URL}/users/:userId/prompts`, ({ request, params }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") || "0"); + const size = Number(url.searchParams.get("size") || "4"); + const { userId } = params; + + return HttpResponse.json({ + success: true, + status: 200, + data: { + content: [ + { + promptId: 1024 + page, + title: `${userId}번 유저의 ${page + 1}페이지 프롬프트`, + description: "자소서를 위한 GPT 프롬프트 설명입니다.", + hasPreview: true, + canCopy: true, + createdAt: "2025-12-23T14:00:00", + isLiked: true, + }, + ], + pageInfo: { + currentPage: page, + pageSize: size, + totalElements: 45, + totalPages: Math.ceil(45 / size), + isLast: page >= 11, + }, + }, + }); + }), + http.get(`${BASE_URL}/users/:userId/liked`, ({ request, params }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") || "0"); + const size = Number(url.searchParams.get("size") || "4"); + const { userId } = params; + + return HttpResponse.json({ + success: true, + status: 200, + data: { + content: [ + { + promptId: 1024 + page, + title: `${userId}번 유저의 ${page + 1}페이지 프롬프트`, + description: "자소서를 위한 GPT 프롬프트 설명입니다.", + hasPreview: true, + canCopy: true, + createdAt: "2025-12-23T14:00:00", + isLiked: true, + }, + ], + pageInfo: { + currentPage: page, + pageSize: size, + totalElements: 45, + totalPages: Math.ceil(45 / size), + isLast: page >= 11, + }, + }, + }); + }), + http.put(`${BASE_URL}/users/me/profile`, async ({ request }) => { + const updateData = await request.json(); + + const isProfanity = JSON.stringify(updateData).includes("바보"); + + if (isProfanity) { + return HttpResponse.json( + { + success: false, + code: "400", + error: { + errorClassName: "PROFANITY_DETECTED", + message: "사용할 수 없는 단어가 포함되어 있습니다: [바보]", + }, + timestamp: new Date().toISOString(), + }, + { status: 400 } + ); + } + return HttpResponse.json( + { + success: true, + code: "200", + data: { + message: "프로필 정보가 성공적으로 저장되었습니다.", + }, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); + }), +]; diff --git a/mocks/handlers/onboarding-handlers.ts b/mocks/handlers/onboarding-handlers.ts index 0875c83..62cf34f 100644 --- a/mocks/handlers/onboarding-handlers.ts +++ b/mocks/handlers/onboarding-handlers.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; -const BASE_URL = "http://localhost:8080"; +const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_API_URL; export const onboardingHandlers = [ http.put(`${BASE_URL}/users/me/onboarding/basic`, async () => { diff --git a/package-lock.json b/package-lock.json index e6b8d26..49d4de6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.3.1", + "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "ky": "^1.14.3", "lucide-react": "^0.562.0", @@ -50,6 +51,7 @@ "@storybook/nextjs": "^10.1.11", "@storybook/nextjs-vite": "^10.1.11", "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -159,6 +161,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -5980,6 +5983,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.16" }, @@ -6014,7 +6018,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -6035,7 +6038,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -6097,8 +6099,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6215,6 +6216,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6252,6 +6260,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6262,6 +6271,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6337,6 +6347,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -6836,6 +6847,7 @@ "integrity": "sha512-t4toy8X/YTnjYEPoY0pbDBg3EvDPg1elCDrfc+VupPHwoN/5/FNQ8Z+xBYIaEnOE2vVEyKwqYBzZ9h9rJtZVcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/mocker": "4.0.16", "@vitest/utils": "4.0.16", @@ -6859,6 +6871,7 @@ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.0.16", "@vitest/mocker": "4.0.16", @@ -7114,6 +7127,7 @@ "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" @@ -7358,6 +7372,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7423,6 +7438,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8309,6 +8325,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9317,7 +9334,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -9386,8 +9402,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-converter": { "version": "0.2.0", @@ -9840,6 +9855,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9903,6 +9919,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10003,6 +10020,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10104,6 +10122,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12231,6 +12250,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -12865,7 +12893,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13131,6 +13158,7 @@ "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -13259,6 +13287,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.0.tgz", "integrity": "sha512-Y+KbmDbefYtHDDQKLNrmzE/YYzG2msqo2VXhzh5yrJ54tx/6TmGdkR5+kP9ma7i7LwZpZMfoY3m/AoPPPKxtVw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.0", "@swc/helpers": "0.5.15", @@ -14086,6 +14115,7 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -14152,6 +14182,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14322,6 +14353,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -14352,6 +14384,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14392,7 +14425,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -14408,7 +14440,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -14419,7 +14450,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -14432,8 +14462,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process": { "version": "0.11.10", @@ -14562,6 +14591,7 @@ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", "license": "MIT", + "peer": true, "dependencies": { "fast-diff": "^1.3.0", "lodash.clonedeep": "^4.5.0", @@ -14607,6 +14637,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14661,6 +14692,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14720,6 +14752,7 @@ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15296,6 +15329,7 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15528,6 +15562,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15974,6 +16009,7 @@ "integrity": "sha512-pKP5jXJYM4OjvNklGuHKO53wOCAwfx79KvZyOWHoi9zXUH5WVMFUe/ZfWyxXG/GTcj0maRgHGUjq/0I43r0dDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.0", @@ -16623,6 +16659,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16861,6 +16898,7 @@ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=12.20" }, @@ -16952,6 +16990,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17298,6 +17337,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17453,6 +17493,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17466,6 +17507,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -17653,6 +17695,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17731,6 +17774,7 @@ "integrity": "sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-html-community": "0.0.8", "html-entities": "^2.1.0", @@ -18265,6 +18309,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 13f4cb2..9359f25 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.3.1", + "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "ky": "^1.14.3", "lucide-react": "^0.562.0", @@ -61,6 +62,7 @@ "@storybook/nextjs": "^10.1.11", "@storybook/nextjs-vite": "^10.1.11", "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/proxy.ts b/proxy.ts index ba19f0f..819348f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,7 +10,7 @@ export default auth((req) => { const isSignIn = !!session; const isNewUser = session?.isNewUser; - const protectedRoutes = [ROUTES.mypage.ROOT]; + const protectedRoutes = ["ROUTES.mypage.ROOT"]; const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route) diff --git a/queries/api/auth.ts b/queries/api/auth.ts index e73689a..0c07574 100644 --- a/queries/api/auth.ts +++ b/queries/api/auth.ts @@ -1,3 +1,5 @@ +import { ApiResponse, fetcher } from "@/lib/fetcher"; + export const postAuth = async ({ provider, authCode, @@ -25,3 +27,24 @@ export const postAuth = async ({ } return response.json(); }; + +export const postLogout = async () => { + return await fetcher.post("auth/logout").json>(); +}; + +export interface WithdrawRequest { + reason?: string; +} + +export const deleteUserAccount = async ( + uid: string | number, + data?: WithdrawRequest +) => { + return await fetcher.delete(`users/${uid}`, { json: data }).json< + ApiResponse<{ + uid: string; + unlinkedProvider: string; + terminatedAt: string; + }> + >(); +}; diff --git a/queries/api/mypage.ts b/queries/api/mypage.ts new file mode 100644 index 0000000..e995293 --- /dev/null +++ b/queries/api/mypage.ts @@ -0,0 +1,66 @@ +import { ApiResponse, fetcher } from "@/lib/fetcher"; + +import { UserProfileData, UserPromptsResponse } from "./types"; + +export const getUserProfile = async () => { + return await fetcher + .get(`users/me/profile`) + .json>(); +}; + +interface GetUserPromptsParams { + userId: string | number; + page?: number; + size?: number; + sort?: string; +} + +export const getUserPrompts = async ({ + userId, + page = 0, + size = 4, + sort = "", +}: GetUserPromptsParams): Promise => { + return await fetcher + .get(`users/${userId}/prompts`, { + searchParams: { page, size, sort }, + }) + .json(); +}; + +export const getLikedPrompts = async ({ + userId, + page = 0, + size = 4, + sort = "", +}: GetUserPromptsParams): Promise => { + return await fetcher + .get(`users/${userId}/liked`, { + searchParams: { page, size, sort }, + }) + .json(); +}; + +export interface UpdateProfileRequest { + basicInfo: { + nickname: string; + introduction: string; + }; + careerInfo: { + currentStatus: string; + targetJob: string[]; + careerYear: number; + education: string; + major: string; + }; + selfIntro: { + experiences: string[]; + keywords: string[]; + }; +} + +export const updateUserProfile = async (data: UpdateProfileRequest) => { + return await fetcher + .put("users/me/profile", { json: data }) + .json>(); +}; diff --git a/queries/api/types.ts b/queries/api/types.ts new file mode 100644 index 0000000..0d25b2b --- /dev/null +++ b/queries/api/types.ts @@ -0,0 +1,64 @@ +import { + EDUCATION_OPTIONS, + STATE_VALUES, +} from "@/app/(beforeLogin)/(auth)/constant"; +import { ApiResponse } from "@/lib/fetcher"; + +// 마이페이지 유저 정보 응답타입 +export type StateKey = keyof typeof STATE_VALUES; +export type EducationValue = (typeof EDUCATION_OPTIONS)[number]["value"]; + +export interface BasicInfo { + uid: string | number; + nickname: string; + email: string; + provider: "KAKAO" | "NAVER"; + introduction: string | null; +} + +export interface CareerInfo { + currentStatus: StateKey[]; + targetJob: string[]; + careerYear: string; + education: EducationValue; + major: string; +} + +export interface SelfIntro { + experiences: string[]; + keywords: string[]; +} + +export interface UserProfileData { + basicInfo: BasicInfo; + careerInfo: CareerInfo; + selfIntro: SelfIntro; +} + +export type UserProfileResponse = ApiResponse; + +// 마이페이지 좋아요한 프롬프트 리스트 응답타입 +export interface PromptItem { + promptId: number; + title: string; + description: string; + hasPreview: boolean; + canCopy: boolean; + createdAt: string; + isLiked: boolean; +} + +export interface PageInfo { + currentPage: number; + pageSize: number; + totalElements: number; + totalPages: number; + isLast: boolean; +} + +export interface UserPromptsData { + content: PromptItem[]; + pageInfo: PageInfo; +} + +export type UserPromptsResponse = ApiResponse; From dc81db581b307a544df9f125ccfffee8069921c9 Mon Sep 17 00:00:00 2001 From: fryzke Date: Sat, 21 Feb 2026 23:25:07 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20proxy=20=EC=9B=90=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EB=B3=B5=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy.ts b/proxy.ts index 819348f..ba19f0f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,7 +10,7 @@ export default auth((req) => { const isSignIn = !!session; const isNewUser = session?.isNewUser; - const protectedRoutes = ["ROUTES.mypage.ROOT"]; + const protectedRoutes = [ROUTES.mypage.ROOT]; const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route) From 39fd2869205fae9d58f95f0619a37a1c0757584d Mon Sep 17 00:00:00 2001 From: fryzke Date: Sun, 22 Feb 2026 00:37:18 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=9A=A9=20Zustand=20store=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(afterLogin)/mypage/MypageActivity.tsx | 4 +- app/(afterLogin)/mypage/MypageLeftSection.tsx | 8 ++- .../mypage/MypageRightSection.tsx | 55 +++++++--------- app/(afterLogin)/mypage/page.tsx | 6 +- app/(beforeLogin)/(auth)/constant.ts | 63 +++++++++++++++---- app/store/mypageStore.ts | 34 ++++++++++ components/mypage/user-experience.tsx | 6 +- components/mypage/user-keyword.tsx | 3 +- mocks/handlers/mypage-handlers.ts | 4 +- mocks/handlers/onboarding-handlers.ts | 3 +- stories/Mypage/UserExperience.stories.tsx | 4 ++ stories/Mypage/UserKeyword.stories.tsx | 3 + 12 files changed, 136 insertions(+), 57 deletions(-) create mode 100644 app/store/mypageStore.ts diff --git a/app/(afterLogin)/mypage/MypageActivity.tsx b/app/(afterLogin)/mypage/MypageActivity.tsx index b6896ca..38bd315 100644 --- a/app/(afterLogin)/mypage/MypageActivity.tsx +++ b/app/(afterLogin)/mypage/MypageActivity.tsx @@ -16,7 +16,9 @@ export default function MypageActivitySection() { const searchParams = useSearchParams(); // 1. 현재 탭 및 페이지 상태 관리 - const activeSub = (searchParams.get("sub") as "liked" | "posted") || "liked"; + const rawSub = searchParams.get("sub"); + const activeSub: "liked" | "posted" = + rawSub === "posted" ? "posted" : "liked"; const currentPage = Number(searchParams.get("page")) || 0; const userId = 1; // 실제로는 인증 정보나 프로필 훅에서 가져온 ID 사용 diff --git a/app/(afterLogin)/mypage/MypageLeftSection.tsx b/app/(afterLogin)/mypage/MypageLeftSection.tsx index 19b6b79..b512796 100644 --- a/app/(afterLogin)/mypage/MypageLeftSection.tsx +++ b/app/(afterLogin)/mypage/MypageLeftSection.tsx @@ -29,7 +29,10 @@ export default function MypageLeftSection() { router.refresh(); } catch (error) { Cookies.remove("accessToken"); + Cookies.remove("refreshToken"); console.error("Logout failed:", error); + //TODO: error 컴포넌트가 생기면 사용자 피드백 주기 + //toasts.error("로그아웃 처리 중 오류가 발생했습니다."); } } }; @@ -42,10 +45,9 @@ export default function MypageLeftSection() { if (!isConfirmed || !userData) return; try { - await deleteUserAccount(userData.basicInfo.uid, { - reason: "서비스 이용 불편", - }); + await deleteUserAccount(userData.basicInfo.uid); + Cookies.remove("refreshToken"); Cookies.remove("accessToken"); queryClient.clear(); diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx index c34ac9e..5867b8b 100644 --- a/app/(afterLogin)/mypage/MypageRightSection.tsx +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -5,17 +5,14 @@ import { useEffect, useState } from "react"; import { EDUCATION_OPTIONS, - JOB_MAP, JobType, - REVERSE_JOB_MAP, - REVERSE_STATE_MAP, - STATE_MAP, STATE_VALUES, - StateType, + transformCareerInfoToState, + transformStateToPayload, } from "@/app/(beforeLogin)/(auth)/constant"; import { StatusSelect } from "@/app/(beforeLogin)/(auth)/signup/component/StatusSelect"; import { TargetJobsSelect } from "@/app/(beforeLogin)/(auth)/signup/component/TargetJobsSelect"; -import { useSignupStore } from "@/app/store/signUpStore"; +import { useMypageStore } from "@/app/store/mypageStore"; import UserExperience from "@/components/mypage/user-experience"; import UserKeyword from "@/components/mypage/user-keyword"; import { BaseButton } from "@/components/shared/button"; @@ -41,30 +38,24 @@ export default function MypageRightSection() { career, educationLevel, updateField, - } = useSignupStore(); + reset, + } = useMypageStore(); + + useEffect(() => { + return () => reset(); + }, [reset]); - // 1. 서버 데이터 로드 시 초기화 및 타입 싱크 useEffect(() => { if (profile) { const { careerInfo, selfIntro } = profile; - // 직무 변환 (영문 코드 배열 -> 한글 라벨 배열) - const jobsInKorean = careerInfo.targetJob.map( - (code) => JOB_MAP.get(code) || code - ); - setTargetJobs(jobsInKorean as JobType[]); - - // 상태 변환 (배열의 첫 번째 요소 추출 -> 한글 라벨) - const serverStateKey = careerInfo.currentStatus[0]; - const stateInKorean = STATE_MAP.get(serverStateKey) || ""; + const normalized = transformCareerInfoToState(careerInfo); - // Zustand 스토어 타입에 맞게 단언하여 업데이트 - updateField("currentState", stateInKorean as "" | StateType); + setTargetJobs(normalized.targetJobs); + updateField("currentState", normalized.currentState); + updateField("educationLevel", normalized.educationLevel); + updateField("career", normalized.career); - updateField("educationLevel", careerInfo.education); - const serverCareer = careerInfo.careerYear; - const careerNumber = serverCareer === "신입" ? 0 : Number(serverCareer); - updateField("career", careerNumber); setExperiences(selfIntro.experiences || []); setKeywords(selfIntro.keywords || []); } @@ -87,16 +78,15 @@ export default function MypageRightSection() { } }; - // 2. 저장 핸들러 const handleGlobalSave = () => { if (!profile) return; - const targetJobCodes = targetJobs.map( - (label) => REVERSE_JOB_MAP.get(label) || label - ); - - const currentStateCode = - REVERSE_STATE_MAP.get(currentState) || currentState; + const careerPayload = transformStateToPayload({ + currentState, + targetJobs, + career, + educationLevel, + }); const payload: UpdateProfileRequest = { basicInfo: { @@ -104,10 +94,7 @@ export default function MypageRightSection() { introduction: profile.basicInfo.introduction || "", }, careerInfo: { - currentStatus: currentStateCode, - targetJob: targetJobCodes, - careerYear: career, - education: educationLevel, + ...careerPayload, major: profile.careerInfo.major || "", }, selfIntro: { diff --git a/app/(afterLogin)/mypage/page.tsx b/app/(afterLogin)/mypage/page.tsx index 3741ca6..bf49d90 100644 --- a/app/(afterLogin)/mypage/page.tsx +++ b/app/(afterLogin)/mypage/page.tsx @@ -11,7 +11,11 @@ interface Props { export default async function MyPage({ searchParams }: Props) { const { tab } = await searchParams; - const activeTab = (tab as "profile" | "activity") || "profile"; + const VALID_TABS = ["profile", "activity"] as const; + type ActiveTab = (typeof VALID_TABS)[number]; + const activeTab: ActiveTab = VALID_TABS.includes(tab as ActiveTab) + ? (tab as ActiveTab) + : "profile"; return (
diff --git a/app/(beforeLogin)/(auth)/constant.ts b/app/(beforeLogin)/(auth)/constant.ts index 2f0f32d..2f096f7 100644 --- a/app/(beforeLogin)/(auth)/constant.ts +++ b/app/(beforeLogin)/(auth)/constant.ts @@ -25,26 +25,69 @@ export const JOB_DATA = { export const STATE_MAP = new Map( Object.entries(STATE_VALUES) as [string, string][] ); - export const JOB_MAP = new Map( Object.entries(JOB_DATA) as [string, string][] ); - export const REVERSE_STATE_MAP = new Map( (Object.entries(STATE_VALUES) as [string, string][]).map(([k, v]) => [v, k]) ); - export const REVERSE_JOB_MAP = new Map( (Object.entries(JOB_DATA) as [string, string][]).map(([k, v]) => [v, k]) ); -export const STATE_OPTIONS = Object.values(STATE_VALUES); -export const JOB_OPTIONS = Object.values(JOB_DATA); - export type StateType = (typeof STATE_VALUES)[keyof typeof STATE_VALUES]; +export type StateKey = keyof typeof STATE_VALUES; export type JobType = (typeof JOB_DATA)[keyof typeof JOB_DATA]; -export type JobTypeCode = keyof typeof JOB_DATA; +export type EducationValue = + | "HIGH_SCHOOL" + | "ASSOCIATE" + | "BACHELOR" + | "MASTER" + | "DOCTOR"; + +export const transformCareerInfoToState = (careerInfo: { + currentStatus: string[]; + targetJob: string[]; + careerYear: string; + education: string; +}) => { + const stateInKorean = STATE_MAP.get(careerInfo.currentStatus[0]) || ""; + const jobsInKorean = careerInfo.targetJob.map( + (code) => JOB_MAP.get(code) || code + ); + const careerNumber = + careerInfo.careerYear === "신입" ? 0 : Number(careerInfo.careerYear); + return { + currentState: stateInKorean as "" | StateType, + targetJobs: jobsInKorean as JobType[], + career: careerNumber, + educationLevel: careerInfo.education as EducationValue, + }; +}; + +export const transformStateToPayload = (state: { + currentState: string; + targetJobs: string[]; + career: number; + educationLevel: string; +}) => { + const stateCode = + REVERSE_STATE_MAP.get(state.currentState) || state.currentState; + const jobCodes = state.targetJobs.map( + (label) => REVERSE_JOB_MAP.get(label) || label + ); + + return { + currentStatus: String(stateCode), + targetJob: jobCodes, + careerYear: state.career, + education: state.educationLevel, + }; +}; + +export const STATE_OPTIONS = Object.values(STATE_VALUES); +export const JOB_OPTIONS = Object.values(JOB_DATA); export const EDUCATION_OPTIONS: SelectOption[] = [ { label: "고등학교 졸업", value: "HIGH_SCHOOL" }, { label: "전문대 졸업", value: "ASSOCIATE" }, @@ -52,9 +95,3 @@ export const EDUCATION_OPTIONS: SelectOption[] = [ { label: "석사 졸업", value: "MASTER" }, { label: "박사 졸업", value: "DOCTOR" }, ]; - -export const FIELD_OPTIONS: SelectOption[] = [ - { label: "인문계열", value: "HUMANITIES" }, - { label: "이공계열", value: "ENGINEERING_SCIENCE" }, - { label: "예체능계열", value: "ARTS_PHYSICAL" }, -]; diff --git a/app/store/mypageStore.ts b/app/store/mypageStore.ts new file mode 100644 index 0000000..c4bf920 --- /dev/null +++ b/app/store/mypageStore.ts @@ -0,0 +1,34 @@ +import { create } from "zustand"; + +import { + EducationValue, + JobType, + StateType, +} from "@/app/(beforeLogin)/(auth)/constant"; + +interface ProfileState { + currentState: "" | StateType; + targetJobs: JobType[]; + career: number; + educationLevel: "" | EducationValue; + + // 상태 변경 액션 + updateField: ( + field: K, + value: ProfileState[K] + ) => void; + setTargetJobs: (jobs: JobType[]) => void; + reset: () => void; +} + +export const useMypageStore = create((set) => ({ + currentState: "", + targetJobs: [], + career: 0, + educationLevel: "", + + updateField: (field, value) => set((state) => ({ ...state, [field]: value })), + setTargetJobs: (jobs) => set({ targetJobs: jobs }), + reset: () => + set({ currentState: "", targetJobs: [], career: 0, educationLevel: "" }), +})); diff --git a/components/mypage/user-experience.tsx b/components/mypage/user-experience.tsx index b37bc5d..8794544 100644 --- a/components/mypage/user-experience.tsx +++ b/components/mypage/user-experience.tsx @@ -25,7 +25,11 @@ export default function UserExperience({ const addItem = () => { const trimmedValue = inputValue.trim(); - if (trimmedValue && value.length < maxItems) { + if ( + trimmedValue && + !value.includes(trimmedValue) && + value.length < maxItems + ) { onChange([...value, trimmedValue]); setInputValue(""); } diff --git a/components/mypage/user-keyword.tsx b/components/mypage/user-keyword.tsx index 47cff5b..3d6e07b 100644 --- a/components/mypage/user-keyword.tsx +++ b/components/mypage/user-keyword.tsx @@ -87,7 +87,8 @@ export default function UserKeyword({ {value.length < maxTags && (
diff --git a/mocks/handlers/mypage-handlers.ts b/mocks/handlers/mypage-handlers.ts index 2c700c2..99de84d 100644 --- a/mocks/handlers/mypage-handlers.ts +++ b/mocks/handlers/mypage-handlers.ts @@ -64,7 +64,7 @@ export const mypageHandlers = [ pageSize: size, totalElements: 45, totalPages: Math.ceil(45 / size), - isLast: page >= 11, + isLast: page >= Math.ceil(45 / size) - 1, }, }, }); @@ -95,7 +95,7 @@ export const mypageHandlers = [ pageSize: size, totalElements: 45, totalPages: Math.ceil(45 / size), - isLast: page >= 11, + isLast: page >= Math.ceil(45 / size) - 1, }, }, }); diff --git a/mocks/handlers/onboarding-handlers.ts b/mocks/handlers/onboarding-handlers.ts index 62cf34f..3a72062 100644 --- a/mocks/handlers/onboarding-handlers.ts +++ b/mocks/handlers/onboarding-handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from "msw"; -const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_API_URL; +const BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_API_URL ?? "http://localhost:8080/api/v1"; export const onboardingHandlers = [ http.put(`${BASE_URL}/users/me/onboarding/basic`, async () => { diff --git a/stories/Mypage/UserExperience.stories.tsx b/stories/Mypage/UserExperience.stories.tsx index b897809..18535ed 100644 --- a/stories/Mypage/UserExperience.stories.tsx +++ b/stories/Mypage/UserExperience.stories.tsx @@ -10,6 +10,10 @@ const meta: Meta = { layout: "centered", }, tags: ["autodocs"], + args: { + label: "경험", + placeholder: "경험을 입력해주세요", + }, argTypes: { label: { control: { type: "text" }, diff --git a/stories/Mypage/UserKeyword.stories.tsx b/stories/Mypage/UserKeyword.stories.tsx index 1381255..ec4fcb0 100644 --- a/stories/Mypage/UserKeyword.stories.tsx +++ b/stories/Mypage/UserKeyword.stories.tsx @@ -10,6 +10,9 @@ const meta: Meta = { layout: "centered", }, tags: ["autodocs"], + args: { + label: "키워드", + }, argTypes: { label: { control: { type: "text" }, From 86ad706c0540de486a3d710e180c49fae7c3972b Mon Sep 17 00:00:00 2001 From: fryzke Date: Sun, 22 Feb 2026 01:14:37 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(afterLogin)/mypage/MypageRightSection.tsx | 11 +++++++++-- app/(beforeLogin)/(auth)/constant.ts | 3 ++- mocks/handlers/auth-handlers.ts | 3 ++- mocks/handlers/mypage-handlers.ts | 6 +++--- mocks/handlers/nickname-handlers.ts | 3 ++- mocks/handlers/prompt-list-handlers.ts | 11 ++++++----- mocks/handlers/terms-handlers.ts | 3 ++- stories/Mypage/UserExperience.stories.tsx | 1 + 8 files changed, 27 insertions(+), 14 deletions(-) diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx index 5867b8b..c7975d6 100644 --- a/app/(afterLogin)/mypage/MypageRightSection.tsx +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { EDUCATION_OPTIONS, + EducationValue, JobType, STATE_VALUES, transformCareerInfoToState, @@ -24,7 +25,7 @@ import { UpdateProfileRequest } from "@/queries/api/mypage"; export default function MypageRightSection() { const { data: profile, isLoading } = useUserProfile(); - const { mutate: updateProfile } = useUpdateProfile(); + const { mutate: updateProfile, isPending } = useUpdateProfile(); const [otherInput, setOtherInput] = useState(""); const [experiences, setExperiences] = useState([]); @@ -45,6 +46,7 @@ export default function MypageRightSection() { return () => reset(); }, [reset]); + //TODO: 약관동의 동의 여부를 조회가능한 API 있는지 확인 useEffect(() => { if (profile) { const { careerInfo, selfIntro } = profile; @@ -138,7 +140,9 @@ export default function MypageRightSection() { updateField("educationLevel", val)} + onValueChange={(val) => + updateField("educationLevel", val as EducationValue) + } selectOptions={EDUCATION_OPTIONS} />
@@ -167,6 +171,7 @@ export default function MypageRightSection() { 변경사항 저장 @@ -204,6 +209,7 @@ export default function MypageRightSection() { 변경사항 저장 @@ -243,6 +249,7 @@ export default function MypageRightSection() { 변경사항 저장 diff --git a/app/(beforeLogin)/(auth)/constant.ts b/app/(beforeLogin)/(auth)/constant.ts index 2f096f7..6a99c9f 100644 --- a/app/(beforeLogin)/(auth)/constant.ts +++ b/app/(beforeLogin)/(auth)/constant.ts @@ -55,8 +55,9 @@ export const transformCareerInfoToState = (careerInfo: { const jobsInKorean = careerInfo.targetJob.map( (code) => JOB_MAP.get(code) || code ); + const parsed = Number(careerInfo.careerYear); const careerNumber = - careerInfo.careerYear === "신입" ? 0 : Number(careerInfo.careerYear); + careerInfo.careerYear === "신입" || !Number.isFinite(parsed) ? 0 : parsed; return { currentState: stateInKorean as "" | StateType, diff --git a/mocks/handlers/auth-handlers.ts b/mocks/handlers/auth-handlers.ts index a8a6235..b52eb84 100644 --- a/mocks/handlers/auth-handlers.ts +++ b/mocks/handlers/auth-handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from "msw"; -const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_API_URL; +const BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_API_URL ?? "http://localhost:8080/api/v1"; export const authHandlers = [ http.post(`${BASE_URL}/auth/social-login`, async ({ request }) => { diff --git a/mocks/handlers/mypage-handlers.ts b/mocks/handlers/mypage-handlers.ts index 99de84d..eb9bf18 100644 --- a/mocks/handlers/mypage-handlers.ts +++ b/mocks/handlers/mypage-handlers.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw"; import { UserProfileResponse, UserPromptsResponse } from "@/queries/api/types"; const BASE_URL = - process.env.NEXT_PUBLIC_BACKEND_API_URL || "http://localhost:8080/api/v1"; + process.env.NEXT_PUBLIC_BACKEND_API_URL ?? "http://localhost:8080/api/v1"; export const mypageHandlers = [ http.get(`${BASE_URL}/users/me/profile`, ({ request }) => { @@ -64,7 +64,7 @@ export const mypageHandlers = [ pageSize: size, totalElements: 45, totalPages: Math.ceil(45 / size), - isLast: page >= Math.ceil(45 / size) - 1, + isLast: page >= 11, }, }, }); @@ -95,7 +95,7 @@ export const mypageHandlers = [ pageSize: size, totalElements: 45, totalPages: Math.ceil(45 / size), - isLast: page >= Math.ceil(45 / size) - 1, + isLast: page >= 11, }, }, }); diff --git a/mocks/handlers/nickname-handlers.ts b/mocks/handlers/nickname-handlers.ts index 7e1b449..c149f92 100644 --- a/mocks/handlers/nickname-handlers.ts +++ b/mocks/handlers/nickname-handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from "msw"; -const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_API_URL; +const BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_API_URL ?? "http://localhost:8080/api/v1"; export const nicknameHandlers = [ http.get(`${BASE_URL}/users/nickname/availability`, ({ request }) => { diff --git a/mocks/handlers/prompt-list-handlers.ts b/mocks/handlers/prompt-list-handlers.ts index 0e98415..fa26af2 100644 --- a/mocks/handlers/prompt-list-handlers.ts +++ b/mocks/handlers/prompt-list-handlers.ts @@ -216,11 +216,12 @@ interface ReportRequestBody { reasonDetail: string; } -const BASE_URL = "http://localhost:8080"; +const BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_API_URL ?? "http://localhost:8080/api/v1"; export const promptListHandlers = [ //API 명세서 업데이트되면 맞춰서 수정해야함. - http.get(`${BASE_URL}/api/prompts`, ({ request }) => { + http.get(`${BASE_URL}/prompts`, ({ request }) => { const url = new URL(request.url); const category = url.searchParams.get("category"); @@ -249,7 +250,7 @@ export const promptListHandlers = [ isLast: isLast, }); }), - http.post(`${BASE_URL}/api/prompts/like/:promptId`, ({ params }) => { + http.post(`${BASE_URL}/prompts/like/:promptId`, ({ params }) => { const { promptId } = params; return HttpResponse.json( @@ -260,7 +261,7 @@ export const promptListHandlers = [ { status: 200 } ); }), - http.delete(`${BASE_URL}/api/prompts/like/:promptId`, ({ params }) => { + http.delete(`${BASE_URL}/prompts/like/:promptId`, ({ params }) => { const { promptId } = params; console.log(`Prompt ${promptId} 좋아요 취소됨`); @@ -273,7 +274,7 @@ export const promptListHandlers = [ { status: 200 } ); }), - http.post(`${BASE_URL}/api/reports`, async ({ request }) => { + http.post(`${BASE_URL}/reports`, async ({ request }) => { const body = (await request.json()) as ReportRequestBody; const { targetType, targetId, reason, reasonDetail } = body; diff --git a/mocks/handlers/terms-handlers.ts b/mocks/handlers/terms-handlers.ts index ae328a8..8473d74 100644 --- a/mocks/handlers/terms-handlers.ts +++ b/mocks/handlers/terms-handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from "msw"; -const BASE_URL = "http://localhost:8080"; +const BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_API_URL ?? "http://localhost:8080/api/v1"; export const termsHandlers = [ http.get(`${BASE_URL}/terms`, () => { diff --git a/stories/Mypage/UserExperience.stories.tsx b/stories/Mypage/UserExperience.stories.tsx index 18535ed..6229558 100644 --- a/stories/Mypage/UserExperience.stories.tsx +++ b/stories/Mypage/UserExperience.stories.tsx @@ -13,6 +13,7 @@ const meta: Meta = { args: { label: "경험", placeholder: "경험을 입력해주세요", + onChange: () => {}, }, argTypes: { label: { From 402a71188e87b772e515f9d70f56a5c4a6277a75 Mon Sep 17 00:00:00 2001 From: fryzke Date: Sun, 22 Feb 2026 01:30:47 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=85=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=88=98=EC=A0=95=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=201=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(afterLogin)/mypage/MypageRightSection.tsx | 5 ++++- app/(beforeLogin)/(auth)/constant.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx index c7975d6..f636529 100644 --- a/app/(afterLogin)/mypage/MypageRightSection.tsx +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { EDUCATION_OPTIONS, @@ -47,8 +47,11 @@ export default function MypageRightSection() { }, [reset]); //TODO: 약관동의 동의 여부를 조회가능한 API 있는지 확인 + const initialized = useRef(false); useEffect(() => { if (profile) { + if (initialized.current) return; + initialized.current = true; const { careerInfo, selfIntro } = profile; const normalized = transformCareerInfoToState(careerInfo); diff --git a/app/(beforeLogin)/(auth)/constant.ts b/app/(beforeLogin)/(auth)/constant.ts index 6a99c9f..7cc6b0b 100644 --- a/app/(beforeLogin)/(auth)/constant.ts +++ b/app/(beforeLogin)/(auth)/constant.ts @@ -80,7 +80,7 @@ export const transformStateToPayload = (state: { ); return { - currentStatus: String(stateCode), + currentStatus: stateCode, targetJob: jobCodes, careerYear: state.career, education: state.educationLevel, From 28412acbe7877b16398afa5f8b1d61f951ddc7d1 Mon Sep 17 00:00:00 2001 From: fryzke Date: Sun, 22 Feb 2026 02:02:10 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EC=83=81=ED=83=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B8=B0=ED=83=80=20=EC=84=A0=ED=83=9D=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=ED=95=9C=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(afterLogin)/mypage/MypageRightSection.tsx | 1 + app/(beforeLogin)/(auth)/constant.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx index f636529..24c9682 100644 --- a/app/(afterLogin)/mypage/MypageRightSection.tsx +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -89,6 +89,7 @@ export default function MypageRightSection() { const careerPayload = transformStateToPayload({ currentState, targetJobs, + otherInput, career, educationLevel, }); diff --git a/app/(beforeLogin)/(auth)/constant.ts b/app/(beforeLogin)/(auth)/constant.ts index 7cc6b0b..9a6515a 100644 --- a/app/(beforeLogin)/(auth)/constant.ts +++ b/app/(beforeLogin)/(auth)/constant.ts @@ -69,18 +69,22 @@ export const transformCareerInfoToState = (careerInfo: { export const transformStateToPayload = (state: { currentState: string; + otherInput?: string; targetJobs: string[]; career: number; educationLevel: string; }) => { - const stateCode = - REVERSE_STATE_MAP.get(state.currentState) || state.currentState; + const isOther = state.currentState === STATE_VALUES.OTHER; + const stateCode = isOther + ? state.otherInput || "OTHER" + : REVERSE_STATE_MAP.get(state.currentState) || state.currentState; + const jobCodes = state.targetJobs.map( (label) => REVERSE_JOB_MAP.get(label) || label ); return { - currentStatus: stateCode, + currentStatus: String(stateCode), targetJob: jobCodes, careerYear: state.career, education: state.educationLevel, From 7c25050fb1e7750070aed19f7fbe0e63bb2d07b0 Mon Sep 17 00:00:00 2001 From: fryzke Date: Sun, 22 Feb 2026 02:12:15 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EA=B8=B0=ED=83=80=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=9E=80=20=EB=B9=88=EC=B9=B8=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(afterLogin)/mypage/MypageRightSection.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx index 24c9682..47d7fbe 100644 --- a/app/(afterLogin)/mypage/MypageRightSection.tsx +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -86,6 +86,11 @@ export default function MypageRightSection() { const handleGlobalSave = () => { if (!profile) return; + if (currentState === STATE_VALUES.OTHER && !otherInput.trim()) { + alert("기타 상태를 직접 입력해주세요."); + //toasts.error("기타 상태를 직접 입력해주세요."); + return; + } const careerPayload = transformStateToPayload({ currentState, targetJobs,