Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lodash.debounce": "^4.0.8",
"next": "14.2.25",
"react": "^18",
Expand All @@ -25,6 +26,7 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^20",
"@types/react": "^18",
Expand Down
3 changes: 1 addition & 2 deletions src/app/(after-login)/mypage/_components/BackButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"use client";

import { useRouter } from "next/navigation";
import Image from "next/image";
import BackIcon from "../../../../../public/icon/arrow_right_icon.svg";
import BackIcon from "public/icon/arrow_right_icon.svg";

export default function BackButton() {
const router = useRouter();
Expand Down
113 changes: 105 additions & 8 deletions src/app/(after-login)/mypage/_components/PasswordSection.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,122 @@
"use client";

import Cookies from "js-cookie";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { updatePassword } from "@/lib/apis/accountApi";
import { useAlertStore } from "@/lib/store/useAlertStore";
import Button from "@/components/common/button/Button";
import Input from "@/components/common/input/Input";

const passwordSchema = z
.object({
password: z.string().min(1, "현재 비밀번호를 입력해주세요."),
newPassword: z.string().min(8, "새 비밀번호는 8자 이상이어야 합니다."),
confirmNewPassword: z.string().min(1, "새 비밀번호를 다시 입력해주세요."),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: "새 비밀번호가 일치하지 않습니다.",
path: ["confirmNewPassword"],
});

type PasswordFormData = z.infer<typeof passwordSchema>;

export default function PasswordSection() {
const accessToken = Cookies.get("accessToken") ?? "";
const { openAlert } = useAlertStore();

const {
register,
handleSubmit,
formState: { errors, isValid },
watch,
reset,
} = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
mode: "onBlur",
defaultValues: {
password: "",
newPassword: "",
confirmNewPassword: "",
},
});

const currentPassword = watch("password");
const currentNewPassword = watch("newPassword");

const onSubmit = async (formData: PasswordFormData) => {
try {
await updatePassword({
token: accessToken,
password: formData.password,
newPassword: formData.newPassword,
});
openAlert("profileUpdateSuccess");
reset();
} catch (error) {
if (error instanceof Error) {
const errorInfo = JSON.parse(error.message);
if (errorInfo.status === 401) {
openAlert("wrongPassword");
} else {
openAlert("profileUpdateFailed");
}
} else {
openAlert("profileUpdateFailed");
}
}
};

const isFormChanged = currentPassword !== "" || currentNewPassword !== "";

return (
<div className="w-full p-4 rounded-lg bg-white tablet:p-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full p-4 rounded-lg bg-white tablet:p-6"
>
<div className="flex flex-col gap-10 tablet:gap-6">
<div className="font-bold text-2lg text-gray-800 tablet:text-2xl">
비밀번호 변경
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
{/* 로그인/회원가입 페이지에서 한 것 처럼 패스워드 토글 버튼 넣어도 좋을 것 같네요 */}
<Input type="password" label="현재 비밀번호" />
<Input type="password" label="새 비밀번호" />
<Input type="password" label="새 비밀번호 확인" />
<Input
type="password"
label="현재 비밀번호"
placeholder="비밀번호 입력"
hasIcon="right"
{...register("password")}
error={!!errors.password}
errorMessage={errors.password?.message}
/>
<Input
type="password"
label="새 비밀번호"
placeholder="새 비밀번호 입력"
hasIcon="right"
{...register("newPassword")}
error={!!errors.newPassword}
errorMessage={errors.newPassword?.message}
/>
<Input
type="password"
label="새 비밀번호 확인"
placeholder="새 비밀번호 입력"
hasIcon="right"
{...register("confirmNewPassword")}
error={!!errors.confirmNewPassword}
errorMessage={errors.confirmNewPassword?.message}
/>
</div>
<Button variant="purple">변경</Button>
<Button
variant="purple"
type="submit"
disabled={!isValid || !isFormChanged}
>
변경
</Button>
</div>
</div>
</div>
</form>
);
}
143 changes: 120 additions & 23 deletions src/app/(after-login)/mypage/_components/ProfileSection.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,153 @@
"use client";

import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UserInfo } from "@/lib/types";
import { fetchUser } from "@/lib/apis/usersApi";
import {
fetchUser,
updateProfile,
uploadProfileImage,
} from "@/lib/apis/accountApi";
import { useAlertStore } from "@/lib/store/useAlertStore";
import Button from "@/components/common/button/Button";
import ImageInput from "@/components/common/input/ImageInput";
import Input from "@/components/common/input/Input";

const profileSchema = z.object({
nickname: z
.string()
.min(1, "닉네임을 입력해주세요.")
.max(10, "닉네임은 10자 이하로 작성해주세요."),
});

type ProfileFormData = z.infer<typeof profileSchema>;

export default function ProfileSection() {
const [data, setData] = useState<UserInfo | null>(null);
const accessToken = localStorage.getItem("accessToken") ?? "";
const [loading, setLoading] = useState(true);
const [profileImageUrl, setProfileImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState<string | null>(null);
const [initialData, setInitialData] = useState<UserInfo | null>(null);
const accessToken = Cookies.get("accessToken") ?? "";
const { openAlert } = useAlertStore();

const {
register,
handleSubmit,
formState: { errors, isValid },
setValue,
watch,
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
mode: "onBlur",
});

const currentNickname = watch("nickname");

useEffect(() => {
const getData = async () => {
const res = await fetchUser({
token: accessToken,
});
setData(res);
setLoading(true);
try {
const res = await fetchUser({ token: accessToken });
setData(res);
setInitialData(res);
setValue("nickname", res.nickname);
setProfileImageUrl(res.profileImageUrl);
} catch {
openAlert("userNotFound");
} finally {
setLoading(false);
}
};
if (accessToken) {
getData();
} else {
setLoading(false);
}
}, [accessToken, setValue, openAlert]);

const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

getData();
}, []);
setImageError(null);
try {
const res = await uploadProfileImage({ token: accessToken, image: file });
setProfileImageUrl(res.profileImageUrl);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Failed to fetch")) {
setImageError("이미지 파일의 용량이 5MB를 넘습니다.");
} else {
setImageError(error.message);
}
} else {
setImageError("이미지 업로드에 실패했습니다.");
}
}
};

if (!data) return;
const onSubmit = async (formData: ProfileFormData) => {
try {
const updatedData = await updateProfile({
token: accessToken,
nickname: formData.nickname,
profileImageUrl: profileImageUrl ?? undefined,
});
setData(updatedData);
openAlert("profileUpdateSuccess");
} catch {
openAlert("profileUpdateFailed");
}
};

const { email, nickname, profileImageUrl } = data;
const isFormChanged =
(currentNickname && currentNickname !== initialData?.nickname) ||
(profileImageUrl && profileImageUrl !== initialData?.profileImageUrl);

// 이 밑으로 formData(닉네임, 이미지) state 생성하시고, 기본값으로 각각 위의 nickname, profileImageUrl 넣어주시면 될 거예요
// 아래는 vercel 배포를 위해 위 값들을 임시로 사용한 테스트 코드라 나중에 삭제해주시면 됩니다
console.log(nickname);
console.log(profileImageUrl);
if (loading) return <p>로딩 중...</p>;
if (!data) return <p>정보를 불러오지 못했습니다.</p>;

return (
<div className="w-full p-4 rounded-lg bg-white tablet:p-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full p-4 rounded-lg bg-white tablet:p-6"
>
<div className="flex flex-col gap-10 tablet:gap-6">
<div className="font-bold text-2lg text-gray-800 tablet:text-2xl">
프로필
</div>
<div className="flex flex-col gap-10 tablet:flex-row tablet:gap-[42px]">
<div>
<ImageInput variant="profile" />
<div className="flex flex-col gap-2">
<ImageInput
variant="profile"
initialImageUrl={profileImageUrl}
onChange={handleImageUpload}
/>
{imageError && <p className="text-red text-sm">{imageError}</p>}
</div>
<div className="flex flex-col gap-6 grow">
<div className="flex flex-col gap-4">
{/* 이메일 인풋에는 나중에 disabled 속성 들어가야 합니다 */}
<Input label="이메일" value={email} />
<Input label="닉네임" />
<Input label="이메일" value={data.email} disabled />
<Input
label="닉네임"
{...register("nickname")}
error={!!errors.nickname}
errorMessage={errors.nickname?.message}
/>
</div>
<Button variant="purple">저장</Button>
<Button
variant="purple"
type="submit"
disabled={!isValid || !isFormChanged}
>
저장
</Button>
</div>
</div>
</div>
</div>
</form>
);
}
Loading