diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 34ba6eb..ba9d8bf 100755 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,50 +1,136 @@ "use client"; -import { Button, Input } from "@/shared/ui"; +import { Button, InputLabel } from "@/shared/ui"; +import { Controller, SubmitHandler, useForm } from "react-hook-form"; +import { useCallback, useState } from "react"; -import { useState } from "react"; +import { InputLabelStatus } from "@/shared/ui/InputLabel/InputLabel"; +import axios from "axios"; +import { debounce } from "lodash"; +import { toast } from "sonner"; +import usePostNickname from "@/entities/user/api/usePostNickname"; +import { useRouter } from "next/navigation"; +import useValidateNickname from "@/entities/user/api/useValidateNickname"; + +type SignUpForm = { + nickname: string; +}; const SignUpPage = () => { - const [borderColor, setBorderColor] = useState("#4F118C"); + const { + control, + handleSubmit, + formState: { errors }, + setError, + clearErrors, + } = useForm(); + + const [status, setStatus] = useState("default"); + const [message, setMessage] = useState(""); + + const router = useRouter(); + + const validateNickname = useValidateNickname(); + const postNickname = usePostNickname(); + + const validationCheckNickname = useCallback( + debounce((nickname: string) => { + validateNickname.mutate( + { nickname }, + { + onSuccess: (data) => { + const validateCheck = data.data; + if (validateCheck && validateCheck.status === 200) { + clearErrors("nickname"); // 유효성 검사 성공 시 에러 지우기 + setStatus("correct"); + setMessage("사용 가능한 닉네임입니다."); + } + }, + onError: (error) => { + console.error(error); + if (axios.isAxiosError(error)) { + const errorMessage = + error.response?.data?.message || "서버 오류가 발생했습니다."; + setError("nickname", { + type: "manual", + message: errorMessage, + }); + setStatus("error"); + } else { + setError("nickname", { + type: "manual", + message: "알 수 없는 오류가 발생했습니다.", + }); + } + }, + }, + ); + }, 500), + [], + ); + + const handleChangeNickname = (nickname: string) => { + validationCheckNickname(nickname); + }; - // TODO: 디바운싱 적용 - // TODO: 닉네임 상태관리 - // TODO: Validation Check - // TODO: 중복 ID 체크 API - // TODO: border 색상 관리 + const updateNickname: SubmitHandler = (data) => { + postNickname.mutate( + { nickname: data.nickname }, + { + onSuccess: (data) => { + toast("닉네임이 성공적으로 업데이트됐어요"); + router.push("/"); + }, + }, + ); + }; return (
-
-
-
- 닉네임을 적어주세요! -
-
-
- 시ː작에서 사용할 닉네임을 적어주세요. -
-
- 닉네임은 나중에 수정할 수 있어요. -
-
-
-
-
-
닉네임 입력
- {/* TODO: 이 방식이 맞는지 확인 필요 */} -
- -
-
- -
-
+
+ ( + { + field.onChange(e.target.value); // react-hook-form의 onChange 호출 + handleChangeNickname(e.target.value); // 디바운스된 유효성 검사 호출 + }} + onBlur={field.onBlur} // react-hook-form의 onBlur 호출 + value={field.value} // field.value를 통해 입력값 전달 + status={status} + message={errors.nickname?.message || message} // 메시지 처리 + /> + )} + /> + +
); }; diff --git a/src/app/user/[id]/page.tsx b/src/app/user/[id]/page.tsx index 1ae84d4..b2829d0 100755 --- a/src/app/user/[id]/page.tsx +++ b/src/app/user/[id]/page.tsx @@ -1,63 +1,230 @@ "use client"; -import { Button, Input, InputLabel } from "@/shared/ui"; +import { Button, InputLabel, UnifiedDialog } from "@/shared/ui"; +import { Controller, SubmitHandler, useForm } from "react-hook-form"; +import { LoginUserInfo, PatchUserAddress } from "@/entities/user/model/user"; +import { deleteCookie, getCookie } from "cookies-next"; +import { useEffect, useState } from "react"; -import { User } from "@/entities/user/model/user"; -import { useState } from "react"; +import { InputLabelStatus } from "@/shared/ui/InputLabel/InputLabel"; +import { SquareLoader } from "react-spinners"; +import axios from "axios"; +import { debounce } from "lodash"; +import { toast } from "sonner"; +import { useGeoLocation } from "@/shared/lib/useGeolocation"; +import useGetLoginUserInfo from "@/entities/user/api/useGetLoginUserInfo"; +import useGetLogout from "@/features/authentication/api/usePostLogout"; +import usePatchUserAddress from "@/entities/user/api/usePatchUserAddress"; +import usePatchUserInfo from "@/entities/user/api/usePatchUserInfo"; +import usePostLogout from "@/features/authentication/api/usePostLogout"; +import { useRouter } from "next/navigation"; +import useValidateNickname from "@/entities/user/api/useValidateNickname"; export const runtime = "edge"; +type UserInfoForm = { + nickname: string; + address: string; +}; + const UserInfoPage = () => { - // TODO: 전역 로그인된 유저 정보로 수정 - const [loginedUser, setLoginedUser] = useState({ - id: 1, - account_email: "jkb2221@gmail.com", - profile_image: - "https://avatars.githubusercontent.com/u/33307948?s=400&u=a642bbeb47b47e203f37b47db12d2d92d8f98580&v=4", - name: "kyubumjang", + const [loginedUser, setLoginedUser] = useState({ + id: 0, + email: "", + nickname: "", gender: "male", - age_range: "20~29", - applied_class: [ + age_range: "", + birth: "", + phone_number: "", + latitude: 0, + longitude: 0, + location: "", + }); + const [user, setUser] = useState(); + const [openLogoutDialog, setOpenLogoutDialog] = useState(false); + + const router = useRouter(); + const accessToken = getCookie("accessToken"); + const geolocation = useGeoLocation(); + + const { data, isLoading, isSuccess } = useGetLoginUserInfo(); + const postLogout = usePostLogout(); + + const validateNickname = useValidateNickname(); + const patchUserAddress = usePatchUserAddress(); + const patchUserInfo = usePatchUserInfo(); + + const { + control, + handleSubmit, + formState: { errors }, + setError, + setValue, + clearErrors, + } = useForm({ + defaultValues: { + nickname: data?.data.data.nickname, + address: data?.data.data.location, + }, + }); + + const [status, setStatus] = useState("default"); + const [message, setMessage] = useState(""); + + const validationCheckNickname = debounce((nickname: string) => { + validateNickname.mutate( + { nickname }, { - id: 1, - name: "디지털카메라초급(눈으로 사진찍기)", - description: - "컴팩트 카메라부터 DSLR 카메라까지 디지털 카메라에 대해서 이해하고 카메라의 모든 기능을 200% 활용하는데 목적을 둔다 ** 사진입문자를 위한 수업입니다. ** 3개월 동안 사진 완전초보를 벗어날 수 있도록 도와드립니다. **야외수업시 보험가입 필수 (1일 보험료 별도) 보험가입증서 제출 또는 동의서 작성", - price: 90000, - day_of_week: "수", - time: "2024-09-16 18:00:00", - capacity: 15, - link: "https://www.songpawoman.org/2024/epit_contents.asp?epit_num=10501042&om=202410&ucode=&period=3", - location: "서울 송파", - latitude: 108, - longitude: 108, - target: "사진 입문자", - status: "모집 중", - thumbnail: - "https://images.unsplash.com/photo-1601134991665-a020399422e3?q=80&w=2787&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - like: true, - location_detail: "송파여성문화회관 미디어1실(101호)", - hosted_by: "송파여성문화회관", - address: "서울특별시 송파구 백제고분로42길 5", - division: "oneDay", - distance: "1km", - category: "문화", - condition: "", - period: { startData: "2024-09-09", endDate: "2024-09-09", total: 1 }, - detail: "", - certification: "", - textbookName: "", - textbookPrice: 0, - need: "", - instructorName: "", - instructorHistory: [], - educationPlan: "", + onSuccess: (data) => { + const validateCheck = data.data; + if (validateCheck && validateCheck.status === 200) { + clearErrors("nickname"); // 유효성 검사 성공 시 에러 지우기 + setStatus("correct"); + setMessage("사용 가능한 닉네임입니다."); + } + }, + onError: (error) => { + console.error(error); + if (axios.isAxiosError(error)) { + const errorMessage = + error.response?.data?.message || "서버 오류가 발생했습니다."; + setError("nickname", { + type: "manual", + message: errorMessage, + }); + setStatus("error"); + } else { + setError("nickname", { + type: "manual", + message: "알 수 없는 오류가 발생했습니다.", + }); + } + }, }, - ], - latitude: 37.5059054977082, - longitude: 127.109788230628, - city: "서울특별시", - }); + ); + }, 500); + + const handleChangeNickname = (nickname: string) => { + validationCheckNickname(nickname); + }; + + const updateCurrentPosition = () => { + if (user) { + patchUserAddress.mutate( + { + latitude: user.latitude, + longitude: user.longitude, + }, + { + onSuccess: (data) => { + setValue("address", data.data.data.address); + }, + }, + ); + } + }; + + const updateUserInfo: SubmitHandler = (data) => { + patchUserInfo.mutate( + { + address: data.address, + nickname: data.nickname, + }, + { + onSuccess: () => { + toast("유저 정보 업데이트가 성공적으로 됐어요."); + window.location.reload(); + }, + }, + ); + }; + + const logout = () => { + postLogout.mutate(undefined, { + onSuccess: () => { + deleteCookie("accessToken"); + deleteCookie("refreshToken"); + }, + }); + }; + + const handleLogout = () => { + logout(); + + toast("로그아웃 성공"); + router.push("/"); + }; + + useEffect(() => { + if (data && isSuccess) { + setLoginedUser(data.data.data); + } + }, [data, isSuccess]); + + useEffect(() => { + if ( + geolocation.curLocation && + geolocation.curLocation.latitude && + geolocation.curLocation.longitude + ) { + setUser((prev) => { + return { + ...prev, + latitude: geolocation.curLocation + ? geolocation.curLocation.latitude + : 0, + longitude: geolocation.curLocation + ? geolocation.curLocation.longitude + : 0, + }; + }); + } + }, [geolocation.curLocation]); + + const triggerItem = () => { + return ( +
+
+ 로그아웃 +
+
+ ); + }; + + const dialogContent = () => { + return ( +
+
로그아웃 하시겠어요?
+
+
+ +
+
+ +
+
+
+ ); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } return ( loginedUser && ( @@ -68,69 +235,119 @@ const UserInfoPage = () => {
-
{loginedUser.name}님
+
+ {loginedUser.nickname}님 +
{/* TODO: 연령대, 주소 조건문 처리 */}
- {loginedUser.age_range}대, {loginedUser.city} + {loginedUser.age_range}대, {loginedUser.location}
-
-
- -
- +
+
+ ( + { + field.onChange(e.target.value); // react-hook-form의 onChange 호출 + handleChangeNickname(e.target.value); // 디바운스된 유효성 검사 호출 + }} + onBlur={field.onBlur} + value={field.value} + status={status} + message={errors.nickname?.message || message} // 메시지 처리 + /> + )} /> -
- +
+ ( + { + field.onChange(e.target.value); // react-hook-form의 onChange 호출 + }} + onBlur={field.onBlur} + value={field.value} + /> + )} + /> +
+ +
+ + + +
- - - -
-
-
-
- -
+ +
+
diff --git a/src/entities/user/api/index.ts b/src/entities/user/api/index.ts new file mode 100644 index 0000000..463c7a4 --- /dev/null +++ b/src/entities/user/api/index.ts @@ -0,0 +1,67 @@ +import { + GetLoginUserInfo, + PatchUserAddress, + PatchUserInfo, + PostNickname, + ValidateNickname, +} from "../model/user"; + +import apiRequest from "@/shared/api"; +import { getCookie } from "cookies-next"; + +const BASE_PATH = "/api/mypage"; +const NICKNAME_BASE_PATH = "/api/nickname"; + +// FIXME: 테스트 필요 +export const getLoginUserInfo = () => + apiRequest.get(`${BASE_PATH}`, { + headers: { + Authorization: `Bearer ${getCookie("accessToken")}`, + }, + }); + +export const patchUserAddress = ( + payload: PatchUserAddress["Request"]["body"], +) => + apiRequest.patch( + `${BASE_PATH}/address`, + payload, + { + headers: { + Authorization: `Bearer ${getCookie("accessToken")}`, + }, + }, + ); + +export const patchUserInfo = (payload: PatchUserInfo["Request"]["body"]) => + apiRequest.patch(`${BASE_PATH}`, payload, { + headers: { + Authorization: `Bearer ${getCookie("accessToken")}`, + }, + }); + +export const validateNickname = ( + params: ValidateNickname["Request"]["query"], +) => + apiRequest.post( + `${NICKNAME_BASE_PATH}/validate`, + {}, + { + headers: { + Authorization: `Bearer ${getCookie("accessToken")}`, + }, + params, + }, + ); + +export const postNickname = (params: PostNickname["Request"]["query"]) => + apiRequest.post( + `${NICKNAME_BASE_PATH}`, + {}, + { + headers: { + Authorization: `Bearer ${getCookie("accessToken")}`, + }, + params, + }, + ); diff --git a/src/entities/user/api/useGetLoginUserInfo.ts b/src/entities/user/api/useGetLoginUserInfo.ts new file mode 100755 index 0000000..4f962bd --- /dev/null +++ b/src/entities/user/api/useGetLoginUserInfo.ts @@ -0,0 +1,16 @@ +import { USER_KEYS } from "@/shared/api/keyFactory"; +import { getLoginUserInfo } from "."; +import { useQuery } from "@tanstack/react-query"; + +const useGetLoginUserInfo = () => { + return useQuery({ + queryKey: USER_KEYS.lists(), + queryFn: () => getLoginUserInfo(), + select: (response) => response, + meta: { + errorMessage: "Failed to fetch Login User Info", + }, + }); +}; + +export default useGetLoginUserInfo; diff --git a/src/entities/user/api/usePatchUserAddress.ts b/src/entities/user/api/usePatchUserAddress.ts new file mode 100644 index 0000000..3165428 --- /dev/null +++ b/src/entities/user/api/usePatchUserAddress.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { PatchUserAddress } from "../model/user"; +import { USER_KEYS } from "@/shared/api/keyFactory"; +import { patchUserAddress } from "."; + +const usePatchUserAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: PatchUserAddress["Request"]["body"]) => + patchUserAddress(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: USER_KEYS.lists() }); + }, + }); +}; + +export default usePatchUserAddress; diff --git a/src/entities/user/api/usePatchUserInfo.ts b/src/entities/user/api/usePatchUserInfo.ts new file mode 100644 index 0000000..7df8f19 --- /dev/null +++ b/src/entities/user/api/usePatchUserInfo.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { PatchUserInfo } from "../model/user"; +import { USER_KEYS } from "@/shared/api/keyFactory"; +import { patchUserInfo } from "."; + +const usePatchUserInfo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: PatchUserInfo["Request"]["body"]) => + patchUserInfo(payload), + onSuccess: () => { + // queryClient.invalidateQueries({ queryKey: USER_KEYS.lists() }); + }, + }); +}; + +export default usePatchUserInfo; diff --git a/src/entities/user/api/usePostNickname.ts b/src/entities/user/api/usePostNickname.ts new file mode 100644 index 0000000..a7ef441 --- /dev/null +++ b/src/entities/user/api/usePostNickname.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { PostNickname } from "../model/user"; +import { postNickname } from "."; + +const usePostNickname = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: PostNickname["Request"]["query"]) => + postNickname(params), + onSuccess: () => {}, + }); +}; + +export default usePostNickname; diff --git a/src/entities/user/api/useValidateNickname.ts b/src/entities/user/api/useValidateNickname.ts new file mode 100644 index 0000000..63a182c --- /dev/null +++ b/src/entities/user/api/useValidateNickname.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { USER_KEYS } from "@/shared/api/keyFactory"; +import { ValidateNickname } from "../model/user"; +import { validateNickname } from "."; + +const useValidateNickname = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: ValidateNickname["Request"]["query"]) => + validateNickname(params), + onSuccess: () => {}, + }); +}; + +export default useValidateNickname; diff --git a/src/entities/user/model/user.ts b/src/entities/user/model/user.ts old mode 100755 new mode 100644 index 9d15b70..9bd3001 --- a/src/entities/user/model/user.ts +++ b/src/entities/user/model/user.ts @@ -1,14 +1,97 @@ -import { Lecture } from "@/entities/lecture/model/lecture"; +import { BearerAccessTokenHeader } from "@/features/authentication/model/token"; +import { Payload } from "@/shared/model/api"; -export interface User { +export interface LoginUserInfo { id: number; - account_email: string; - profile_image: string; - name: string; - gender: string; + birth: string; + email: string; + gender: "male" | "female"; age_range: string; - applied_class: Lecture[]; + location: string; + nickname: string; + phone_number: string; latitude: number; longitude: number; - city: string; } + +export interface GetLoginUserInfoRes { + data: LoginUserInfo; + message: string; + status: string; +} + +export type GetLoginUserInfo = Payload< + BearerAccessTokenHeader, + undefined, + undefined, + GetLoginUserInfoRes +>; + +export interface ValidateNicknameParams { + nickname: string; +} +export interface ValidateNicknameRes { + status: number; + message: string; + data: string; +} + +export type ValidateNickname = Payload< + BearerAccessTokenHeader, + ValidateNicknameParams, + undefined, + ValidateNicknameRes +>; + +export interface PostNicknameRes { + status: number; + message: string; + data: string; +} + +export type PostNickname = Payload< + BearerAccessTokenHeader, + ValidateNicknameParams, + undefined, + PostNicknameRes +>; + +export interface PatchUserAddressDto { + latitude: number; + longitude: number; +} + +export interface PatchUserAddressData { + address: string; +} + +export interface PatchUserAddressRes { + status: number; + message: string; + data: PatchUserAddressData; +} + +export type PatchUserAddress = Payload< + BearerAccessTokenHeader, + undefined, + PatchUserAddressDto, + PatchUserAddressRes +>; + +export interface PatchUserInfoDto { + nickname: string; + address: string; +} + +export interface PatchUserInfoRes { + status: number; + message: string; + data: string; +} + +export type PatchUserInfo = Payload< + BearerAccessTokenHeader, + undefined, + PatchUserInfoDto, + PatchUserInfoRes +>; diff --git a/src/shared/store/user.ts b/src/shared/store/user.ts new file mode 100644 index 0000000..a1e68b1 --- /dev/null +++ b/src/shared/store/user.ts @@ -0,0 +1,33 @@ +import { LoginUserInfo } from "@/entities/user/model/user"; +import { create } from "zustand"; + +// 상태와 액션의 타입 정의 +export interface LoginedUserState { + loginedUser: LoginUserInfo | null; // LoginUserInfo 타입을 사용 + setLoginedUser: (loginedUserInfo: LoginUserInfo) => void; +} + +// 초기 상태 정의 +const initialState: LoginedUserState = { + loginedUser: { + id: 0, + email: "", + nickname: "", + gender: "male", + age_range: "", + birth: "", + phone_number: "", + latitude: 0, + longitude: 0, + location: "", + }, + setLoginedUser: () => {}, +}; + +// Zustand 스토어 생성 +const useLoginedUserStore = create((set) => ({ + ...initialState, + setLoginedUser: (loginedUserInfo) => set({ loginedUser: loginedUserInfo }), +})); + +export default useLoginedUserStore;