diff --git a/src/app/(auth)/api/login/route.ts b/src/app/(auth)/api/login/route.ts index 786f5a89..f7306c13 100644 --- a/src/app/(auth)/api/login/route.ts +++ b/src/app/(auth)/api/login/route.ts @@ -1,8 +1,6 @@ import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; -export const dynamic = "force-dynamic"; - export async function POST(req: NextRequest) { try { const { token } = await req.json(); diff --git a/src/app/(user-menu)/mypage/page.tsx b/src/app/(user-menu)/mypage/page.tsx index e829662a..15775498 100644 --- a/src/app/(user-menu)/mypage/page.tsx +++ b/src/app/(user-menu)/mypage/page.tsx @@ -1,13 +1,36 @@ "use client"; import { useState } from "react"; +import { useForm } from "react-hook-form"; import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { useToast } from "@/components/ui/use-toast"; import Logo from "@/images/logo.svg"; import { cn } from "@/lib/utils"; +import type { + NicknameSchemaType, + PositionAndStacksSchemaType, +} from "@/schemas/setNickname"; +import { nicknameSchema, positionAndStacksSchema } from "@/schemas/setNickname"; +import { userBioSchema } from "@/schemas/userBioSchema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ResetIcon } from "@radix-ui/react-icons"; +import { Badge } from "@radix-ui/themes"; +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import getPositions from "@/services/steady/getPositions"; +import getStacks from "@/services/steady/getStacks"; +import type { UpdateMyProfileType } from "@/services/types"; +import checkSameNickname from "@/services/user/checkSameNickname"; +import deleteMyProfile from "@/services/user/deleteMyProfile"; +import getMyProfile from "@/services/user/getMyProfile"; +import updateMyProfile from "@/services/user/updateMyProfile"; import Button, { buttonSize } from "@/components/_common/Button"; import Icon from "@/components/_common/Icon"; import Input from "@/components/_common/Input"; import { AlertModal } from "@/components/_common/Modal"; +import { MultiSelector, SingleSelector } from "@/components/_common/Selector"; +import { extractValue } from "@/utils/extractValue"; import { subMyPageTextStyles } from "@/constants/commonStyle"; const subContentStyles = "flex flex-col gap-30"; @@ -15,10 +38,155 @@ const subBoxStyles = "px-30 py-20 gap-30 flex h-116 w-718 items-center rounded-6 border-2 border-st-gray-100"; const MyProfilePage = () => { - const [edit, setEdit] = useState(false); + const [isEditingNickname, setIsEditingNickname] = useState(false); + const [isEditingBio, setIsEditingBio] = useState(false); + const [isEditingPosition, setIsEditingPosition] = useState(false); + const [isEditingStacks, setIsEditingStacks] = useState(false); + const [sameNicknameChecked, setSameNicknameChecked] = useState(false); - const handleClick = () => { - setEdit((prev) => !prev); + const { toast } = useToast(); + const router = useRouter(); + + const { + data: myProfileData, + isPending: myProfileIsLoading, + error: myProfileError, + refetch: myProfileRefetch, + } = useSuspenseQuery({ + queryKey: ["profile"], + queryFn: () => getMyProfile(), + }); + + const profileMutation = useMutation({ + mutationKey: ["profile"], + mutationFn: (data: UpdateMyProfileType) => updateMyProfile(data), + onSuccess: () => { + toast({ description: "프로필 수정에 성공했습니다.", variant: "green" }); + myProfileRefetch(); + }, + onError: () => { + toast({ description: "프로필 수정에 실패했습니다.", variant: "red" }); + }, + }); + + const { data: stacksData } = useSuspenseQuery({ + queryKey: ["stacks"], + queryFn: () => getStacks(), + staleTime: Infinity, + }); + const { data: positionsData } = useSuspenseQuery({ + queryKey: ["positions"], + queryFn: () => getPositions(), + staleTime: Infinity, + }); + // TODO: 프로필 이미지 업로드 기능 구현 + const { nickname, bio, position, stacks, platform } = myProfileData; + + const nicknameForm = useForm({ + mode: "onChange", + resolver: zodResolver(nicknameSchema), + }); + + const positionAndStacksForm = useForm({ + mode: "onChange", + values: { + positionId: position.id, + stacksId: stacks.map((stack) => stack.id), + }, + resolver: zodResolver(positionAndStacksSchema), + }); + + const userBioForm = useForm({ + mode: "onChange", + values: { bio: bio }, + resolver: zodResolver(userBioSchema), + }); + + if (myProfileError) { + return
에러가 발생했습니다.
; + } + + if (myProfileIsLoading) { + return
로딩중...
; + } + + const handleCheckSameNickname = (nickname: string) => { + checkSameNickname(nickname) + .then((res) => { + if (res.exist) { + toast({ description: "이미 사용중인 닉네임입니다.", variant: "red" }); + return; + } else { + toast({ description: "사용 가능한 닉네임입니다!", variant: "green" }); + setSameNicknameChecked(true); + } + }) + .catch((error) => { + console.error(error); + toast({ + description: "닉네임 중복 확인에 실패했습니다.", + variant: "red", + }); + }); + return; + }; + + const handleUpdateNickName = (data: { nickname: string }) => { + if (!sameNicknameChecked) { + toast({ description: "닉네임 중복 확인을 해주세요.", variant: "red" }); + return; + } else { + const newData = { + nickname: data.nickname, + bio: myProfileData.bio, + profileImage: myProfileData.profileImage, + positionId: myProfileData.position.id, + stacksId: myProfileData.stacks.map((stack) => stack.id), + }; + profileMutation.mutate(newData); + setSameNicknameChecked(false); + setIsEditingNickname(false); + } + }; + + const handleUpdatePositionsAndStacks = ( + data: PositionAndStacksSchemaType, + ) => { + const newData = { + nickname: myProfileData.nickname, + bio: myProfileData.bio, + profileImage: myProfileData.profileImage, + ...data, + }; + profileMutation.mutate(newData); + }; + + const stacksInitialData = stacks.map((stack) => ({ + label: stack.name, + value: stack.id.toString(), + })); + + const handleUpdateBio = (data: { bio: string }) => { + const newData = { + nickname: myProfileData.nickname, + bio: data.bio, + profileImage: myProfileData.profileImage, + positionId: myProfileData.position.id, + stacksId: myProfileData.stacks.map((stack) => stack.id), + }; + profileMutation.mutate(newData); + setIsEditingBio(false); + }; + + const handleDeleteAccount = () => { + deleteMyProfile().then((res) => { + if (res.status === 204) { + toast({ description: "회원 탈퇴에 성공했습니다.", variant: "green" }); + router.replace("/logout"); + } else { + toast({ description: "회원 탈퇴에 실패했습니다.", variant: "red" }); + } + }); }; return ( @@ -56,25 +224,69 @@ const MyProfilePage = () => { className="hidden" />
- {edit ? ( -
- - {/* 닉네임 중복 확인 */} - - + { + nicknameForm.reset(); + setSameNicknameChecked(false); + setIsEditingNickname(false); + }} + width={22} + height={22} + /> + + ) : ( + + )} +
+ +
+ )} /> - - + + ) : ( <> {/* TODO: 닉네임 state로 관리 */} -
{"스테디"}
- + setIsEditingPosition(false)} + /> + + ) : ( + <> + {position.name} + + + )} + +
+ {isEditingStacks ? ( + <> + ( + + ({ + value: stack.id.toString(), + label: stack.name, + }))} + onSelectedChange={(selected) => { + positionAndStacksForm.setValue( + "stacksId", + extractValue(selected).map(Number), + ); + }} + /> + + + )} + /> + + setIsEditingStacks(false)} + /> + + ) : ( + <> + {stacks.map((stack) => ( + + {stack.name} + + ))} + + + )} +
+ + + + +
한 줄 소개
-
- -
+
+ + {isEditingBio ? ( + ( +
+ { + field.onChange(value); + }} + /> + + setIsEditingBio(false)} + /> +
+ )} + /> + ) : ( +
+ {bio ?? "한 줄 소개를 입력해주세요."} + +
+ )} + +
+
소셜 인증
- 카카오 로고 -
- 카카오 인증이 완료되었습니다. ✅ -
+ {platform === "KAKAO" && ( + <> + 카카오 로고 +
+ 카카오 인증이 완료되었습니다 ✅ +
+ + )}
@@ -117,14 +527,18 @@ const MyProfilePage = () => { 회원 탈퇴 } actionButton={ diff --git a/src/components/_common/Input/index.tsx b/src/components/_common/Input/index.tsx index de52acbf..0aa2890e 100644 --- a/src/components/_common/Input/index.tsx +++ b/src/components/_common/Input/index.tsx @@ -1,6 +1,4 @@ import type { ComponentProps } from "react"; -import { Pencil1Icon } from "@radix-ui/react-icons"; -import { IconButton } from "@radix-ui/themes"; interface InputProps extends ComponentProps<"input"> { inputName: @@ -46,6 +44,7 @@ const Input = ({ onChange={(event) => { onValueChange?.(event.target.value); }} + {...props} />
); @@ -109,20 +108,17 @@ const Input = ({ break; case "introduce-input": input = ( -
+
{ + onValueChange?.(event.target.value); + }} + {...props} /> - - -
); break; diff --git a/src/components/_common/Modal/LoginModal/LoginStepsContents/SetPositionAndStacks.tsx b/src/components/_common/Modal/LoginModal/LoginStepsContents/SetPositionAndStacks.tsx index 71513db5..9cf989f8 100644 --- a/src/components/_common/Modal/LoginModal/LoginStepsContents/SetPositionAndStacks.tsx +++ b/src/components/_common/Modal/LoginModal/LoginStepsContents/SetPositionAndStacks.tsx @@ -20,7 +20,7 @@ const SetPositionAndStacks = () => { const { nickname, positionId, stacksId, setPositionId, setStackIds } = useNewUserInfoStore(); const userInfos = useForm({ - values: { position: positionId, stacks: stacksId }, + values: { positionId: positionId, stacksId: stacksId }, resolver: zodResolver(positionAndStacksSchema), }); const { data: stacksData } = useSuspenseQuery({ @@ -35,8 +35,8 @@ const SetPositionAndStacks = () => { }); const savePositionAndStacks = (data: PositionAndStacksSchemaType) => { - setPositionId(data.position); - setStackIds(data.stacks); + setPositionId(data.positionId); + setStackIds(data.stacksId); setIncreaseSteps(); }; @@ -61,7 +61,7 @@ const SetPositionAndStacks = () => {
( {
( value.length > 0, { diff --git a/src/schemas/userBioSchema.ts b/src/schemas/userBioSchema.ts new file mode 100644 index 00000000..903fdece --- /dev/null +++ b/src/schemas/userBioSchema.ts @@ -0,0 +1,11 @@ +import * as z from "zod"; + +export const userBioSchema = z.object({ + bio: z + .string({ required_error: "한 줄 소개를 입력해주세요." }) + .refine((value) => value.trim().length <= 80, { + message: "한 줄 소개는 80자 이하여야 합니다.", + }), +}); + +export type UserBioType = z.infer; diff --git a/src/services/user/deleteMyProfile.ts b/src/services/user/deleteMyProfile.ts new file mode 100644 index 00000000..846e283b --- /dev/null +++ b/src/services/user/deleteMyProfile.ts @@ -0,0 +1,16 @@ +import { axiosInstance, isAbnormalCode } from ".."; + +const deleteMyProfile = async () => { + try { + const response = await axiosInstance.delete("/api/v1/user"); + if (isAbnormalCode(response.status)) { + throw new Error("Failed to fetch delete my profile api!"); + } + return response; + } catch (error) { + console.error(error); + throw error; + } +}; + +export default deleteMyProfile; diff --git a/src/services/user/updateMyProfile.ts b/src/services/user/updateMyProfile.ts index 05ffa040..f8b99d48 100644 --- a/src/services/user/updateMyProfile.ts +++ b/src/services/user/updateMyProfile.ts @@ -3,10 +3,7 @@ import type { UpdateMyProfileType } from "../types"; const updateMyProfile = async (payload: UpdateMyProfileType) => { try { - const response = await axiosInstance.patch( - "/api/v1/users/profile", - payload, - ); + const response = await axiosInstance.patch("/api/v1/user/profile", payload); if (isAbnormalCode(response.status)) { throw new Error("Failed to fetch update my profile api!"); }