diff --git a/src/app/(auth)/_components/LoginForm.tsx b/src/app/(auth)/_components/LoginForm.tsx index 9eb48d8a6..b3b649cbe 100644 --- a/src/app/(auth)/_components/LoginForm.tsx +++ b/src/app/(auth)/_components/LoginForm.tsx @@ -1,22 +1,20 @@ "use client"; -import { FieldItem, Input } from "@components/Field"; +import { useRouter } from "next/navigation"; +import { FieldItem, Form, Input } from "@components/Field"; import { Button } from "@components/ui"; import useFormWithError from "@hooks/useFormWithError"; import { zodResolver } from "@hookform/resolvers/zod"; import { signinFormSchmea, SigninFormType } from "@schemas/auth"; import { FieldAdapter } from "@components/adaptor/rhf"; -import { ServerForm } from "@/components/Field/ServerForm"; -import { useActionState } from "react"; import action from "../login/action"; export default function LoginForm() { - const [formStatus, formAction, isPending] = useActionState(action, { - message: "", - }); const { control, - formState: { isValid }, + formError, + handleSubmit, + formState: { isSubmitting, isValid }, } = useFormWithError({ mode: "onBlur", resolver: zodResolver(signinFormSchmea), @@ -25,12 +23,22 @@ export default function LoginForm() { password: "", }, }); + const router = useRouter(); + + async function onSubmit(data: SigninFormType) { + const response = await action(data); + if (response.success) { + router.replace("/items"); + } else { + throw new Error(response.message); + } + } return ( - 이메일 @@ -63,6 +71,6 @@ export default function LoginForm() { - + ); } diff --git a/src/app/(auth)/_components/SignupForm.tsx b/src/app/(auth)/_components/SignupForm.tsx index 66ef2765c..8a7ebc0d7 100644 --- a/src/app/(auth)/_components/SignupForm.tsx +++ b/src/app/(auth)/_components/SignupForm.tsx @@ -1,23 +1,20 @@ "use client"; -import { FieldItem, Input } from "@components/Field"; +import { useRouter } from "next/navigation"; +import { FieldItem, Form, Input } from "@components/Field"; import { Button } from "@components/ui"; import useFormWithError from "@hooks/useFormWithError"; import { zodResolver } from "@hookform/resolvers/zod"; import { signupFormSchema, SignupFormType } from "@schemas/auth"; import { FieldAdapter } from "@components/adaptor/rhf"; import action from "../signup/action"; -import { useActionState } from "react"; -import { ServerForm } from "@/components/Field/ServerForm"; export default function SignupForm() { - const [formStatus, formAction, isPending] = useActionState(action, { - message: "", - }); - const { control, - formState: { isValid }, + formError, + handleSubmit, + formState: { isSubmitting, isValid }, } = useFormWithError({ mode: "onBlur", resolver: zodResolver(signupFormSchema), @@ -28,12 +25,22 @@ export default function SignupForm() { passwordConfirmation: "", }, }); + const router = useRouter(); + + async function onSubmit(data: SignupFormType) { + const response = await action(data); + if (response.success) { + router.replace("/login"); + } else { + throw new Error(response.message); + } + } return ( - 이메일 @@ -92,6 +99,6 @@ export default function SignupForm() { - + ); } diff --git a/src/app/(auth)/login/action.ts b/src/app/(auth)/login/action.ts index 338b13501..85fa71061 100644 --- a/src/app/(auth)/login/action.ts +++ b/src/app/(auth)/login/action.ts @@ -1,19 +1,16 @@ "use server"; import { signIn } from "@/auth"; -import { signinFormSchmea } from "@/schemas/auth"; +import { signinFormSchmea, SigninFormType } from "@/schemas/auth"; import { CredentialsSignin } from "next-auth"; -import { isRedirectError } from "next/dist/client/components/redirect-error"; -import { redirect } from "next/navigation"; -export default async function action( - prevState: { message: string }, - formData: FormData -) { - const parsed = signinFormSchmea.safeParse(Object.fromEntries(formData)); + +export default async function action(data: SigninFormType) { + const parsed = signinFormSchmea.safeParse(data); if (!parsed.success) { return { message: "제출양식에 문제가 있습니다. 확인해주세요", + success: false, }; } @@ -23,20 +20,22 @@ export default async function action( password: parsed.data.password, redirect: false, }); - redirect("/"); - } catch (error) { - if (isRedirectError(error)) { - throw error; - } + return { + message: "로그인 성공", + success: true, + }; + } catch (error) { if (error instanceof CredentialsSignin) { return { message: `로그인 실패 : ${error.code}`, + success: false, }; } - } - return { - message: "로그인 성공", - }; + return { + message: `로그인 실패`, + success: false, + }; + } } diff --git a/src/app/(auth)/signup/action.ts b/src/app/(auth)/signup/action.ts index 55a281dda..00313690c 100644 --- a/src/app/(auth)/signup/action.ts +++ b/src/app/(auth)/signup/action.ts @@ -1,20 +1,16 @@ "use server"; -import { signupFormSchema } from "@/schemas/auth"; +import { signupFormSchema, SignupFormType } from "@/schemas/auth"; import { signUp } from "@/service/auth"; import { isAxiosError } from "axios"; -import { isRedirectError } from "next/dist/client/components/redirect-error"; -import { redirect } from "next/navigation"; -export default async function action( - prevState: { message: string }, - formData: FormData -) { - const parsed = signupFormSchema.safeParse(Object.fromEntries(formData)); +export default async function action(data: SignupFormType) { + const parsed = signupFormSchema.safeParse(data); if (!parsed.success) { return { message: "제출양식에 문제가 있습니다. 확인해주세요", + success: false, }; } @@ -26,22 +22,23 @@ export default async function action( passwordConfirmation: parsed.data.passwordConfirmation, }); - redirect("/login"); + return { + message: "회원가입 성공", + success: true, + }; } catch (error) { - if (isRedirectError(error)) { - throw error; - } - if (isAxiosError(error)) { const message = error.response?.data.message || "알 수 없는 에러가 발생했어요."; return { message: `회원가입 실패 : ${message}`, + success: false, }; } - } - return { - message: "회원가입 성공", - }; + return { + message: "회원가입 실패", + success: false, + }; + } } diff --git a/src/app/(common)/(board)/_components/ArticleAddForm.tsx b/src/app/(common)/(board)/_components/ArticleAddForm.tsx new file mode 100644 index 000000000..ac0ba258e --- /dev/null +++ b/src/app/(common)/(board)/_components/ArticleAddForm.tsx @@ -0,0 +1,10 @@ +"use client"; + +import ArticleForm from "./ArticleForm"; +import useArticleActions from "./useArticleActions"; + +export default function ArticleAddForm() { + const { handleArticleAdd } = useArticleActions(); + + return ; +} diff --git a/src/app/(common)/(board)/_components/ArticleForm.tsx b/src/app/(common)/(board)/_components/ArticleForm.tsx index 8365569fb..5b184d9a5 100644 --- a/src/app/(common)/(board)/_components/ArticleForm.tsx +++ b/src/app/(common)/(board)/_components/ArticleForm.tsx @@ -14,24 +14,24 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Article } from "@/types/article"; import { FieldAdapter } from "@components/adaptor/rhf"; import { useRouter } from "next/navigation"; -import useArticleActions from "./useArticleActions"; import { ArticleFormSchema, ArticleFormType } from "@/schemas/article"; -interface ArticleFormProps { - initialData?: Article; - mode?: "add" | "edit"; - articleId?: number; +interface ArticleAddFormProps { + mode: "add"; + onFormSubmit: (data: ArticleFormType) => Promise
; } +interface ArticleModifyFormProps { + initialData: Article; + mode: "edit"; + onFormSubmit: (data: ArticleFormType) => Promise
; +} + +type ArticleFormProps = ArticleAddFormProps | ArticleModifyFormProps; -export default function ArticleForm({ - initialData, - mode = "add", - articleId, -}: ArticleFormProps) { +export default function ArticleForm(props: ArticleFormProps) { + const { mode, onFormSubmit } = props; + const initialData = mode === "edit" ? props.initialData : undefined; const router = useRouter(); - const { handleArticleAdd, handleArticleModify } = - useArticleActions(articleId); - const onFormSubmit = mode === "add" ? handleArticleAdd : handleArticleModify; const { control, @@ -103,7 +103,16 @@ export default function ArticleForm({ } + render={(props) => ( + { + props.onChange(file); + props.onBlur(); + }} + /> + )} /> diff --git a/src/app/(common)/(board)/_components/ArticleModifyForm.tsx b/src/app/(common)/(board)/_components/ArticleModifyForm.tsx new file mode 100644 index 000000000..d0d4b83c7 --- /dev/null +++ b/src/app/(common)/(board)/_components/ArticleModifyForm.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Article } from "@/types/article"; +import ArticleForm from "./ArticleForm"; +import useArticleActions from "./useArticleActions"; + +export default function ArticleModifyForm({ + initialData, +}: { + initialData: Article; +}) { + const { handleArticleModify } = useArticleActions(initialData.id); + + return ( + + ); +} diff --git a/src/app/(common)/(board)/addBoard/page.tsx b/src/app/(common)/(board)/addBoard/page.tsx index 1e6e08ca7..3e2137c25 100644 --- a/src/app/(common)/(board)/addBoard/page.tsx +++ b/src/app/(common)/(board)/addBoard/page.tsx @@ -1,10 +1,10 @@ import { PageWrapper } from "@/components/Page"; -import ArticleForm from "../_components/ArticleForm"; +import ArticleAddForm from "../_components/ArticleAddForm"; export default function AddBoardPage() { return ( - + ); } diff --git a/src/app/(common)/(board)/boards/(lists)/layout.tsx b/src/app/(common)/(board)/boards/(lists)/layout.tsx index b233600db..c212166ae 100644 --- a/src/app/(common)/(board)/boards/(lists)/layout.tsx +++ b/src/app/(common)/(board)/boards/(lists)/layout.tsx @@ -1,7 +1,7 @@ import { PageWrapper } from "@/components/Page"; import { ReactNode } from "react"; -export default function dss({ +export default function ListLayout({ all, best, children, diff --git a/src/app/(common)/(board)/modifyBoard/[id]/page.tsx b/src/app/(common)/(board)/modifyBoard/[id]/page.tsx index 0ab4c97fb..bdd644180 100644 --- a/src/app/(common)/(board)/modifyBoard/[id]/page.tsx +++ b/src/app/(common)/(board)/modifyBoard/[id]/page.tsx @@ -4,9 +4,9 @@ import { notFound, redirect } from "next/navigation"; import { Suspense } from "react"; import { Message } from "@/components/ui"; import { getArticle } from "@/service/article"; -import ArticleForm from "../../_components/ArticleForm"; import { isAxiosError } from "axios"; import { isRedirectError } from "next/dist/client/components/redirect-error"; +import ArticleModifyForm from "../../_components/ArticleModifyForm"; export default async function ModifyBoardPage({ params, @@ -32,11 +32,7 @@ export default async function ModifyBoardPage({ 게시물정보를 가져오는 중입니다...} > - + ); diff --git a/src/app/(common)/(market)/_components/ProductAddForm.tsx b/src/app/(common)/(market)/_components/ProductAddForm.tsx new file mode 100644 index 000000000..2243cbc47 --- /dev/null +++ b/src/app/(common)/(market)/_components/ProductAddForm.tsx @@ -0,0 +1,10 @@ +"use client"; + +import ProductForm from "./ProductForm"; +import useProductActions from "./useProductActions"; + +export default function ProductAddForm() { + const { handleProductAdd } = useProductActions(); + + return ; +} diff --git a/src/app/(common)/(market)/_components/ProductForm.tsx b/src/app/(common)/(market)/_components/ProductForm.tsx index bd87912ee..228c64ed8 100644 --- a/src/app/(common)/(market)/_components/ProductForm.tsx +++ b/src/app/(common)/(market)/_components/ProductForm.tsx @@ -17,23 +17,24 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Product } from "@type/product"; import { FieldAdapter } from "@components/adaptor/rhf"; import { useRouter } from "next/navigation"; -import useProductActions from "./useProductActions"; -interface ProductFormProps { - initialData?: Product; - mode?: "add" | "edit"; - productId?: number; +interface ProductAddFormProps { + mode: "add"; + onFormSubmit: (data: ProductFormType) => Promise; } -export default function ProductForm({ - initialData, - mode = "add", - productId, -}: ProductFormProps) { +interface ProductModifyFormProps { + initialData: Product; + mode: "edit"; + onFormSubmit: (data: ProductFormType) => Promise; +} + +type ProductFormProps = ProductAddFormProps | ProductModifyFormProps; + +export default function ProductForm(props: ProductFormProps) { + const { mode, onFormSubmit } = props; + const initialData = mode === "edit" ? props.initialData : undefined; const router = useRouter(); - const { handleProductAdd, handleProductModify } = - useProductActions(productId); - const onFormSubmit = mode === "add" ? handleProductAdd : handleProductModify; const { control, @@ -84,7 +85,16 @@ export default function ProductForm({ } + render={(props) => ( + { + props.onChange(file ? [file] : []); + props.onBlur(); + }} + /> + )} /> diff --git a/src/app/(common)/(market)/_components/ProductModifyForm.tsx b/src/app/(common)/(market)/_components/ProductModifyForm.tsx new file mode 100644 index 000000000..bfd67b6a1 --- /dev/null +++ b/src/app/(common)/(market)/_components/ProductModifyForm.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Product } from "@/types/product"; +import ProductForm from "./ProductForm"; +import useProductActions from "./useProductActions"; + +export default function ProductModifyForm({ + initialData, +}: { + initialData: Product; +}) { + const { handleProductModify } = useProductActions(initialData.id); + + return ( + + ); +} diff --git a/src/app/(common)/(market)/addItem/page.tsx b/src/app/(common)/(market)/addItem/page.tsx index 355603c3d..c9bda2240 100644 --- a/src/app/(common)/(market)/addItem/page.tsx +++ b/src/app/(common)/(market)/addItem/page.tsx @@ -1,10 +1,10 @@ import { PageWrapper } from "@/components/Page"; -import ProductForm from "../_components/ProductForm"; +import ProductAddForm from "../_components/ProductAddForm"; export default function AddItemPage() { return ( - + ); } diff --git a/src/app/(common)/(market)/modifyItem/[id]/page.tsx b/src/app/(common)/(market)/modifyItem/[id]/page.tsx index 0f5b35e62..fbecd068b 100644 --- a/src/app/(common)/(market)/modifyItem/[id]/page.tsx +++ b/src/app/(common)/(market)/modifyItem/[id]/page.tsx @@ -1,5 +1,4 @@ import { PageWrapper } from "@/components/Page"; -import ProductForm from "../../_components/ProductForm"; import { getProduct } from "@/service/product"; import { auth } from "@/auth"; import { Suspense } from "react"; @@ -7,6 +6,7 @@ import { Message } from "@/components/ui"; import { notFound, redirect } from "next/navigation"; import { isAxiosError } from "axios"; import { isRedirectError } from "next/dist/client/components/redirect-error"; +import ProductModifyForm from "../../_components/ProductModifyForm"; export default async function ModifyItemPage({ params, @@ -27,11 +27,7 @@ export default async function ModifyItemPage({ return ( 상품정보를 가져오는 중입니다...}> - + ); diff --git a/src/app/(common)/(user)/_components/Activity.module.scss b/src/app/(common)/(user)/_components/Activity.module.scss new file mode 100644 index 000000000..ddd9e5a7d --- /dev/null +++ b/src/app/(common)/(user)/_components/Activity.module.scss @@ -0,0 +1,33 @@ +.info { + display: flex; +} + +.item { + width: 50%; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2rem; + + & + .item:before { + content: ""; + width: 1px; + height: 100%; + background: var(--color-secondary-200); + position: absolute; + left: 0; + top: 0; + } +} + +.label { + opacity: 0.6; + font-weight: 500; +} + +.count { + font-size: 3rem; + font-weight: 700; +} diff --git a/src/app/(common)/(user)/_components/Activity.tsx b/src/app/(common)/(user)/_components/Activity.tsx new file mode 100644 index 000000000..fbfaba4a6 --- /dev/null +++ b/src/app/(common)/(user)/_components/Activity.tsx @@ -0,0 +1,22 @@ +import styles from "./Activity.module.scss"; + +export default function Activity({ + productsCount, + favoritesCount, +}: { + productsCount: number; + favoritesCount: number; +}) { + return ( +
+
+
등록한 상품
+
{productsCount}
+
+
+
좋아요
+
{favoritesCount}
+
+
+ ); +} diff --git a/src/app/(common)/(user)/_components/ChangePasswordForm.tsx b/src/app/(common)/(user)/_components/ChangePasswordForm.tsx new file mode 100644 index 000000000..87a49ff57 --- /dev/null +++ b/src/app/(common)/(user)/_components/ChangePasswordForm.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { FieldItem, Form, Input } from "@components/Field"; +import { Button } from "@components/ui"; +import useFormWithError from "@hooks/useFormWithError"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FieldAdapter } from "@components/adaptor/rhf"; +import { + changePasswordFormSchema, + ChangePasswordFormType, +} from "@/schemas/user"; +import action from "../changePassword/action"; +import FormControl from "./FormControl"; + +export default function ChangePasswordForm() { + const { + control, + formError, + handleSubmit, + formState: { isSubmitting, isValid }, + } = useFormWithError({ + mode: "onBlur", + resolver: zodResolver(changePasswordFormSchema), + defaultValues: { + password: "", + newPassword: "", + newPasswordConfirmation: "", + }, + }); + const router = useRouter(); + + async function onSubmit(data: ChangePasswordFormType) { + const response = await action(data); + if (response.success) { + router.replace("/mypage"); + } else { + throw new Error(response.message); + } + } + + return ( +
+ + 이전 비밀번호 + ( + + )} + /> + + + 새 비밀번호 + ( + + )} + /> + + + + 새 비밀번호 확인 + + ( + + )} + /> + + + + + +
+ ); +} diff --git a/src/app/(common)/(user)/_components/EditProfileForm.module.scss b/src/app/(common)/(user)/_components/EditProfileForm.module.scss new file mode 100644 index 000000000..25498b1ae --- /dev/null +++ b/src/app/(common)/(user)/_components/EditProfileForm.module.scss @@ -0,0 +1,14 @@ +figure.pic { + width: 100%; + height: 100%; + aspect-ratio: 1/1; + max-width: 14rem; + border: 1px solid var(--color-secondary-200); + margin: 0 auto 2rem; +} + +.picButton { + display: flex; + justify-content: center; + margin-bottom: 4rem; +} diff --git a/src/app/(common)/(user)/_components/EditProfileForm.tsx b/src/app/(common)/(user)/_components/EditProfileForm.tsx new file mode 100644 index 000000000..fc2034f1f --- /dev/null +++ b/src/app/(common)/(user)/_components/EditProfileForm.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Avatar, Button } from "@/components/ui"; +import styles from "./EditProfileForm.module.scss"; +import useFormWithError from "@/hooks/useFormWithError"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { editProfileFormSchmea, EditProfileFormType } from "@/schemas/user"; +import { Form } from "@/components/Field"; +import { ChangeEvent, useEffect, useRef } from "react"; +import action from "../editProfile/action"; +import { useRouter } from "next/navigation"; +import FormControl from "./FormControl"; + +export default function EditProfileForm({ + nickname, + image, +}: { + nickname: string; + image: string; +}) { + const { + formError, + watch, + setValue, + handleSubmit, + formState: { isSubmitting, isValid }, + } = useFormWithError({ + resolver: zodResolver(editProfileFormSchmea), + defaultValues: { + image: image || undefined, + }, + }); + const router = useRouter(); + + const imageValue = watch("image"); + const preview = + imageValue instanceof File ? URL.createObjectURL(imageValue) : imageValue; + + const fileRef = useRef(null); + useEffect(() => { + return () => { + if (preview && imageValue instanceof File) { + URL.revokeObjectURL(preview); + } + }; + }, [preview, imageValue]); + + function handleFileChange(e: ChangeEvent) { + if (!e.target.files) return; + + const files = e.target.files; + setValue("image", files[0]); + + if (fileRef.current) { + fileRef.current.value = ""; + } + } + + async function onSubmit(data: EditProfileFormType) { + const response = await action(data); + if (response.success) { + router.replace("/mypage"); + } else { + throw new Error(response.message); + } + } + + return ( +
+ + + +
+ +
+ + + + + + + ); +} diff --git a/src/app/(common)/(user)/_components/FormControl.module.scss b/src/app/(common)/(user)/_components/FormControl.module.scss new file mode 100644 index 000000000..411fc996a --- /dev/null +++ b/src/app/(common)/(user)/_components/FormControl.module.scss @@ -0,0 +1,7 @@ +.control { + display: flex; + gap: 1rem; + justify-content: space-between; + padding-top: 2rem; + border-top: 1px solid var(--color-secondary-200); +} diff --git a/src/app/(common)/(user)/_components/FormControl.tsx b/src/app/(common)/(user)/_components/FormControl.tsx new file mode 100644 index 000000000..1b3392286 --- /dev/null +++ b/src/app/(common)/(user)/_components/FormControl.tsx @@ -0,0 +1,6 @@ +import { PropsWithChildren } from "react"; +import styles from "./FormControl.module.scss"; + +export default function FormControl({ children }: PropsWithChildren) { + return
{children}
; +} diff --git a/src/app/(common)/(user)/_components/Profile.module.scss b/src/app/(common)/(user)/_components/Profile.module.scss new file mode 100644 index 000000000..a72c2049d --- /dev/null +++ b/src/app/(common)/(user)/_components/Profile.module.scss @@ -0,0 +1,34 @@ +.profile { + display: flex; + gap: 2rem; + flex-direction: column; + align-items: center; + text-align: center; + padding-bottom: 4rem; + margin-bottom: 4rem; + border-bottom: 1px solid var(--color-secondary-200); +} + +figure.pic { + width: 100%; + height: 100%; + aspect-ratio: 1/1; + max-width: 14rem; + border: 1px solid var(--color-secondary-200); +} + +.name { + font-weight: 600; + font-size: 2.4rem; + margin-bottom: 0.4em; +} + +.date { + opacity: 0.4; + letter-spacing: -0.04em; +} + +.menu { + display: flex; + gap: 0.4rem; +} diff --git a/src/app/(common)/(user)/_components/Profile.tsx b/src/app/(common)/(user)/_components/Profile.tsx new file mode 100644 index 000000000..2e910702c --- /dev/null +++ b/src/app/(common)/(user)/_components/Profile.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Avatar, Button } from "@/components/ui"; +import styles from "./Profile.module.scss"; +import dayjs from "dayjs"; + +export default function Profile({ + nickname, + image, + createdAt, +}: { + nickname: string; + image: string; + createdAt: string; +}) { + return ( +
+ +
+

{nickname}

+
+ 가입일 : {dayjs(createdAt).format("YYYY-MM-DD")} +
+
+
    +
  • + +
  • +
  • + +
  • +
+
+ ); +} diff --git a/src/app/(common)/(user)/_components/UserWrapper.module.scss b/src/app/(common)/(user)/_components/UserWrapper.module.scss new file mode 100644 index 000000000..7db71bdaa --- /dev/null +++ b/src/app/(common)/(user)/_components/UserWrapper.module.scss @@ -0,0 +1,4 @@ +.container { + max-width: 42rem; + margin: 0 auto; +} diff --git a/src/app/(common)/(user)/_components/UserWrapper.tsx b/src/app/(common)/(user)/_components/UserWrapper.tsx new file mode 100644 index 000000000..ea78960dd --- /dev/null +++ b/src/app/(common)/(user)/_components/UserWrapper.tsx @@ -0,0 +1,6 @@ +import { PropsWithChildren } from "react"; +import styles from "./UserWrapper.module.scss"; + +export default function UserWrapper({ children }: PropsWithChildren) { + return
{children}
; +} diff --git a/src/app/(common)/(user)/changePassword/action.ts b/src/app/(common)/(user)/changePassword/action.ts new file mode 100644 index 000000000..f5abc28f5 --- /dev/null +++ b/src/app/(common)/(user)/changePassword/action.ts @@ -0,0 +1,51 @@ +"use server"; + +import { + changePasswordFormSchema, + ChangePasswordFormType, +} from "@/schemas/user"; +import { changeUserPassword } from "@/service/user"; +import { isAxiosError } from "axios"; +import { revalidatePath } from "next/cache"; + +export default async function action(data: ChangePasswordFormType) { + const parsed = changePasswordFormSchema.safeParse(data); + + if (!parsed.success) { + return { + message: "제출양식에 문제가 있습니다. 확인해주세요", + success: false, + }; + } + const { password, newPassword, newPasswordConfirmation } = parsed.data; + + try { + await changeUserPassword({ + password, + newPassword, + newPasswordConfirmation, + }); + + revalidatePath("/mypage"); + + return { + message: "비밀번호 수정 성공", + success: true, + }; + } catch (error) { + if (isAxiosError(error)) { + console.log(error); + const message = + error.response?.data.message || "알 수 없는 에러가 발생했어요."; + return { + message: `비밀번호 수정 실패 : ${message}`, + success: false, + }; + } + + return { + message: "비밀번호 수정 실패", + success: false, + }; + } +} diff --git a/src/app/(common)/(user)/changePassword/page.tsx b/src/app/(common)/(user)/changePassword/page.tsx new file mode 100644 index 000000000..17e9a2244 --- /dev/null +++ b/src/app/(common)/(user)/changePassword/page.tsx @@ -0,0 +1,5 @@ +import ChangePasswordForm from "../_components/ChangePasswordForm"; + +export default function ChangePasswordPage() { + return ; +} diff --git a/src/app/(common)/(user)/editProfile/action.ts b/src/app/(common)/(user)/editProfile/action.ts new file mode 100644 index 000000000..0dbe0009f --- /dev/null +++ b/src/app/(common)/(user)/editProfile/action.ts @@ -0,0 +1,45 @@ +"use server"; + +import { editProfileFormSchmea, EditProfileFormType } from "@/schemas/user"; +import { editProfileImage, uploadProfileImage } from "@/service/user"; +import { isAxiosError } from "axios"; + +export default async function action(data: EditProfileFormType) { + const parsed = editProfileFormSchmea.safeParse(data); + + if (!parsed.success) { + return { + message: "제출양식에 문제가 있습니다. 확인해주세요", + success: false, + }; + } + const { image } = parsed.data; + + if (image instanceof File) { + try { + const { url } = await uploadProfileImage(image); + await editProfileImage(url); + + return { + message: "프로필 이미지 업데이트 성공", + success: true, + }; + } catch (error) { + if (isAxiosError(error)) { + return { + message: `프로필 이미지 업데이트 실패 : ${error.message}`, + success: false, + }; + } + return { + message: "프로필 이미지 업데이트 실패", + success: false, + }; + } + } else { + return { + message: "이미지를 첨부해주세요", + success: false, + }; + } +} diff --git a/src/app/(common)/(user)/editProfile/loading.tsx b/src/app/(common)/(user)/editProfile/loading.tsx new file mode 100644 index 000000000..ffd010cec --- /dev/null +++ b/src/app/(common)/(user)/editProfile/loading.tsx @@ -0,0 +1,5 @@ +import { Message } from "@/components/ui"; + +export default function Loading() { + return 프로필 정보를 가져오는중입니다...; +} diff --git a/src/app/(common)/(user)/editProfile/page.tsx b/src/app/(common)/(user)/editProfile/page.tsx new file mode 100644 index 000000000..1fc5cffcf --- /dev/null +++ b/src/app/(common)/(user)/editProfile/page.tsx @@ -0,0 +1,15 @@ +import { auth } from "@/auth"; +import EditProfileForm from "../_components/EditProfileForm"; +import { redirect } from "next/navigation"; +import { getUser } from "@/service/user"; + +export default async function EditProfilePage() { + const session = await auth(); + if (!session) { + redirect("/login"); + } + + const { nickname, image } = await getUser(); + + return ; +} diff --git a/src/app/(common)/(user)/layout.tsx b/src/app/(common)/(user)/layout.tsx new file mode 100644 index 000000000..b53d76c29 --- /dev/null +++ b/src/app/(common)/(user)/layout.tsx @@ -0,0 +1,11 @@ +import { PageWrapper } from "@/components/Page"; +import { ReactNode } from "react"; +import UserWrapper from "./_components/UserWrapper"; + +export default function UserLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/(common)/(user)/mypage/loading.tsx b/src/app/(common)/(user)/mypage/loading.tsx new file mode 100644 index 000000000..f35b67f45 --- /dev/null +++ b/src/app/(common)/(user)/mypage/loading.tsx @@ -0,0 +1,5 @@ +import { Message } from "@/components/ui"; + +export default function Loading() { + return 내정보를 불러오는중입니다...; +} diff --git a/src/app/(common)/(user)/mypage/page.tsx b/src/app/(common)/(user)/mypage/page.tsx new file mode 100644 index 000000000..b6e199a22 --- /dev/null +++ b/src/app/(common)/(user)/mypage/page.tsx @@ -0,0 +1,25 @@ +import { auth } from "@/auth"; +import { getUser, getUserActivity } from "@/service/user"; +import { redirect } from "next/navigation"; +import Profile from "../_components/Profile"; +import Activity from "../_components/Activity"; + +export default async function UserPage() { + const session = await auth(); + if (!session) { + redirect("/"); + } + + const { nickname, image, createdAt } = await getUser(); + const { products, favorites } = await getUserActivity(); + + return ( + <> + + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 85c9be714..bb77904a1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,9 @@ import { PropsWithChildren } from "react"; import type { Metadata } from "next"; -import { AxiosInterCeptor } from "@/context/AxiosInterCeptor"; import { SessionProvider } from "next-auth/react"; import "@assets/scss/style.scss"; -import { initServerInterceptor } from "@/service/serverAxios"; import QueryClientProvider from "@/context/QueryClientProvider"; +import { auth } from "@/auth"; export const metadata: Metadata = { title: "판다마켓", @@ -17,19 +16,15 @@ export const metadata: Metadata = { }, }; -// server runtime 환경에서 axios에 interceptor 설정 (server에서만 동작함) -await initServerInterceptor(); +export default async function RootLayout({ children }: PropsWithChildren) { + const session = await auth(); -export default function RootLayout({ children }: PropsWithChildren) { return (
- - - - {children} - + + {children}
diff --git a/src/auth.ts b/src/auth.ts index 1b732b7d4..1df59b6b8 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -26,7 +26,13 @@ declare module "next-auth" { } } -export const { handlers, signIn, signOut, auth } = NextAuth({ +export const { + handlers, + signIn, + signOut, + auth, + unstable_update: update, +} = NextAuth({ providers: [ Credentials({ credentials: { diff --git a/src/components/Field/ImageUpload.tsx b/src/components/Field/ImageUpload.tsx index a7a2bce37..52eedeba1 100644 --- a/src/components/Field/ImageUpload.tsx +++ b/src/components/Field/ImageUpload.tsx @@ -5,38 +5,31 @@ import iconPlus from "@assets/img/icon/icon_plus.svg"; import styles from "./ImageUpload.module.scss"; import Image from "next/image"; -type FileType = File | string | undefined; - interface ImageUploadProps { - value: FileType[] | FileType; - onChange: (file: FileType[] | FileType) => void; + value: File | string | undefined; + onChange: (file: File | undefined) => void; error?: string; placeholder?: string; } export const ImageUpload = forwardRef( ({ value, onChange, error, placeholder }: ImageUploadProps, _) => { - const isArrayValue = Array.isArray(value); - const _value = isArrayValue ? value[0] : value; - const preview = - _value instanceof File ? URL.createObjectURL(_value) : _value; - + const preview = value instanceof File ? URL.createObjectURL(value) : value; const fileRef = useRef(null); useEffect(() => { return () => { - if (preview && _value instanceof File) { + if (preview && value instanceof File) { URL.revokeObjectURL(preview); } }; - }, [preview, _value]); + }, [preview, value]); function handleChange(e: ChangeEvent) { if (!e.target.files) return; const files = e.target.files; - - onChange(isArrayValue ? Array.from(files) : files[0]); + onChange(files[0]); if (fileRef.current) { fileRef.current.value = ""; @@ -47,7 +40,7 @@ export const ImageUpload = forwardRef( if (!fileRef.current) return; fileRef.current.value = ""; - onChange(isArrayValue ? [] : undefined); + onChange(undefined); } return ( diff --git a/src/components/Field/TagsInput.tsx b/src/components/Field/TagsInput.tsx index 6c1d5fc69..16700beed 100644 --- a/src/components/Field/TagsInput.tsx +++ b/src/components/Field/TagsInput.tsx @@ -1,4 +1,4 @@ -import { forwardRef, KeyboardEvent, useRef } from "react"; +import { forwardRef, KeyboardEvent } from "react"; import clsx from "clsx"; import { Tags } from "@components/ui"; import { Error } from "@components/Field"; @@ -13,12 +13,11 @@ interface TagsInputProps { error?: string; } -export const TagsInput = forwardRef( +export const TagsInput = forwardRef( ( { value, onChange, error, isValid, placeholder = "" }: TagsInputProps, - _ + ref ) => { - const inputRef = useRef(null); const valid = isValid && value.length; const css = clsx( styles["field-box"], @@ -26,19 +25,20 @@ export const TagsInput = forwardRef( error && styles.error ); - function handleKeyDown(e: KeyboardEvent) { + function handleKeyDown(e: KeyboardEvent) { if (e.nativeEvent.isComposing) return; if (e.key === "Enter") { e.preventDefault(); - if (!inputRef.current) return; - const tag = inputRef.current.value.trim(); - if (tag && !value.includes(tag)) { - const newTags = [...value, tag]; - onChange(newTags); + const input = e.currentTarget; + const tag = input.value.trim(); + + if (tag) { + const newTags = new Set([...value, tag]); + onChange(Array.from(newTags)); + input.value = ""; } - inputRef.current.value = ""; } } @@ -46,11 +46,12 @@ export const TagsInput = forwardRef( const newTags = value.filter((item) => item !== tag); onChange(newTags); } + return ( <>
+ router.push("/mypage")}> + 내정보 + 로그아웃 diff --git a/src/components/Header/Util.tsx b/src/components/Header/Util.tsx index 1210cb46e..92bc9248e 100644 --- a/src/components/Header/Util.tsx +++ b/src/components/Header/Util.tsx @@ -2,22 +2,26 @@ import { Button } from "@components/ui"; import { Profile } from "@components/Header"; import styles from "./Util.module.scss"; import { auth } from "@/auth"; +import { getUser } from "@/service/user"; export async function Util() { const session = await auth(); - return ( -
- {session?.user ? ( - - ) : ( + if (!session) { + return ( +
- )} +
+ ); + } + + const { nickname, image } = await getUser(); + + return ( +
+
); } diff --git a/src/components/ui/Avatar.module.scss b/src/components/ui/Avatar.module.scss index 29f7a39ed..93573e3c9 100644 --- a/src/components/ui/Avatar.module.scss +++ b/src/components/ui/Avatar.module.scss @@ -8,4 +8,8 @@ &.hover:hover { outline: 2px solid var(--color-secondary-100); } + + img { + object-fit: cover; + } } diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index d518d0ae1..e6ad8f7fe 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -8,16 +8,17 @@ interface AvatarProps { img?: string; nickname: string; hover?: boolean; + className?: string; } -export function Avatar({ nickname, img, hover }: AvatarProps) { +export function Avatar({ nickname, img, hover, className }: AvatarProps) { const avatarImg = img || defaultAvatar; function handleError(e: SyntheticEvent) { e.currentTarget.src = defaultAvatar; } - const css = clsx(styles.avatar, hover && styles.hover); + const css = clsx(styles.avatar, hover && styles.hover, className); return (
diff --git a/src/constants/message.ts b/src/constants/message.ts index 9f7a773e9..bd49b2cf1 100644 --- a/src/constants/message.ts +++ b/src/constants/message.ts @@ -1,3 +1,4 @@ +import { BoardName } from "@/types/comment"; import emptyProductCommentIcon from "@assets/img/icon/icon_inquiry_empty.svg"; import emptyArticleCommentIcon from "@assets/img/icon/icon_reply_empty.svg"; @@ -26,6 +27,8 @@ export const AUTH_VALIDATION_MESSAGES = { PASSWORD_MIN_LENGTH: "비밀번호를 8자 이상 입력해주세요.", PASSWORD_MISMATCH: "비밀번호가 일치하지 않습니다.", USERNAME_REQUIRED: "닉네임을 입력해주세요", + INVALID_IMAGE_SIZE: "이미지 용량을 확인해주세요.", + INVALID_IMAGE_TYPE: "지원하지않는 이미지 형식입니다.", }; export const ARTICLE_VALIDATION_MESSAGE = { @@ -37,30 +40,31 @@ export const ARTICLE_VALIDATION_MESSAGE = { INVALID_IMAGE_COUNT: "1개만 업로드 가능합니다.", }; -export const COMMENT_PLACEHOLDER: { [key: string]: string } = { +export const COMMENT_PLACEHOLDER: Record = { articles: "댓글을 입력해주세요.", products: "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.", }; -export const COMMENT_TITLE: { [key: string]: string } = { +export const COMMENT_TITLE: Record = { articles: "댓글달기", products: "문의하기", }; -export const COMMENT_SUBJECT: { [key: string]: string } = { +export const COMMENT_SUBJECT: Record = { articles: "댓글", products: "문의", }; -export const COMMENT_LOADING: { [key: string]: string } = { +export const COMMENT_LOADING: Record = { articles: "댓글을 더 불러오고 있습니다.", products: "문의를 더 불러오고 있습니다.", }; -export const COMMENT_EMPTY: { - [key: string]: { image: string; message: string }; -} = { +export const COMMENT_EMPTY: Record< + BoardName, + { image: string; message: string } +> = { articles: { image: emptyArticleCommentIcon, message: "아직 댓글이 없어요.\n지금 댓글을 달아보세요!", @@ -71,7 +75,7 @@ export const COMMENT_EMPTY: { }, }; -export const COMMENT_BACK_LINK: { [key: string]: string } = { +export const COMMENT_BACK_LINK: Record = { articles: "/boards", products: "/items", }; diff --git a/src/context/AxiosInterCeptor.tsx b/src/context/AxiosInterCeptor.tsx deleted file mode 100644 index f655fb608..000000000 --- a/src/context/AxiosInterCeptor.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { refreshAccessToken } from "@/service/auth"; -import { useSession } from "next-auth/react"; -import { axiosInstance } from "@/service/axios"; - -async function handleRrefreshToken(refreshToken: string) { - try { - const { accessToken } = await refreshAccessToken(refreshToken); - return accessToken; - } catch (err) { - throw err; - } -} - -export function AxiosInterCeptor() { - const { data, status, update } = useSession(); - - // request inetercepotr : 요청 헤더에 토큰 넣기 (client) - useEffect(() => { - if (status !== "authenticated" || !data.accessToken) return; - - const authInterceptor = axiosInstance.interceptors.request.use( - async function (config) { - if (data?.accessToken) { - config.headers.Authorization = `Bearer ${data.accessToken}`; - } - - return config; - } - ); - - return () => { - axiosInstance.interceptors.request.eject(authInterceptor); - }; - }, [status, data?.accessToken]); - - // response interceptor : 응답 실패시 재발급 - useEffect(() => { - if (!data?.refreshToken) return; - - const refreshInterceptor = axiosInstance.interceptors.response.use( - function (response) { - return response; - }, - async function (error) { - if (error.response?.status === 401 && !error.config._retry) { - try { - error.config._retry = true; - const accessToken = await handleRrefreshToken(data.refreshToken); - - if (accessToken !== data.accessToken) { - // next-auth session update - await update({ accessToken }); - error.config.headers.Authorization = `Bearer ${accessToken}`; - } - return axiosInstance(error.config); - } catch (refreshError) { - console.error("refresh error", refreshError); - return Promise.reject(refreshError); - } - } - - return Promise.reject(error); - } - ); - return () => { - axiosInstance.interceptors.response.eject(refreshInterceptor); - }; - }, [data?.refreshToken, data?.accessToken, update]); - - return null; -} diff --git a/src/context/QueryClientProvider.tsx b/src/context/QueryClientProvider.tsx index fdf683a07..1b2ac4a07 100644 --- a/src/context/QueryClientProvider.tsx +++ b/src/context/QueryClientProvider.tsx @@ -1,13 +1,10 @@ "use client"; -import { - QueryClientProvider as Provider, - QueryClient, -} from "@tanstack/react-query"; +import { getQueryClient } from "@/util/getQueryClient"; +import { QueryClientProvider as Provider } from "@tanstack/react-query"; import { PropsWithChildren } from "react"; -const queryClient = new QueryClient(); - export default function QueryClientProvider({ children }: PropsWithChildren) { + const queryClient = getQueryClient(); return {children}; } diff --git a/src/middleware.ts b/src/middleware.ts index e93fe435d..3256e5398 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,5 +7,13 @@ export default auth((req) => { }); export const config = { - matcher: ["/addBoard", "/modifyBoard/:path", "/addItem", "/modifyItem/:path"], + matcher: [ + "/addBoard", + "/modifyBoard/:path", + "/addItem", + "/modifyItem/:path", + "/mypage", + "/changePassword", + "/editProfile", + ], }; diff --git a/src/schemas/article.ts b/src/schemas/article.ts index 133e6d691..2aad63679 100644 --- a/src/schemas/article.ts +++ b/src/schemas/article.ts @@ -2,33 +2,30 @@ import { z } from "zod"; import { ARTICLE_VALIDATION_MESSAGE as MESSAGE } from "@constants/message"; import { ACCEPT_FILE_TYPES, MAX_FILE_SIZE } from "@constants/file"; +const imageFile = z + .instanceof(File) + .refine( + (file) => { + return file.size <= MAX_FILE_SIZE; + }, + { + message: `${MESSAGE.INVALID_IMAGE_SIZE} (${ + MAX_FILE_SIZE / 1024 / 1024 + }MB 초과)`, + } + ) + .refine( + (file) => { + return ACCEPT_FILE_TYPES.includes(file.type); + }, + { message: MESSAGE.INVALID_IMAGE_TYPE } + ); + export const ArticleFormSchema = z.object({ - image: z - .union([z.instanceof(File), z.string()]) - .optional() - .refine( - (file) => { - if (file instanceof File) { - return file.size <= MAX_FILE_SIZE; - } - return true; - }, - { - message: `${MESSAGE.INVALID_IMAGE_SIZE} (${ - MAX_FILE_SIZE / 1024 / 1024 - }MB 초과)`, - } - ) - .refine( - (file) => { - if (file instanceof File) { - return ACCEPT_FILE_TYPES.includes(file.type); - } - return true; - }, - { message: MESSAGE.INVALID_IMAGE_TYPE } - ), - title: z.string().nonempty({ message: MESSAGE.ARTICLE_TITLE_REQUIRED }), + image: z.union([imageFile, z.string()]).optional(), + title: z.string().nonempty({ + message: MESSAGE.ARTICLE_TITLE_REQUIRED, + }), content: z.string().nonempty({ message: MESSAGE.ARTICLE_CONTENT_REQUIRED, }), diff --git a/src/schemas/product.ts b/src/schemas/product.ts index 209a8586e..ec0ea7c99 100644 --- a/src/schemas/product.ts +++ b/src/schemas/product.ts @@ -2,33 +2,29 @@ import { z } from "zod"; import { PRODUCT_VALIDATION_MESSAGE as MESSAGE } from "@constants/message"; import { ACCEPT_FILE_TYPES, MAX_FILE_SIZE } from "@constants/file"; +const imageFile = z + .instanceof(File) + .refine( + (file) => { + return file.size <= MAX_FILE_SIZE; + }, + { + message: `${MESSAGE.INVALID_IMAGE_SIZE} (${ + MAX_FILE_SIZE / 1024 / 1024 + }MB 초과)`, + } + ) + .refine( + (file) => { + return ACCEPT_FILE_TYPES.includes(file.type); + }, + { message: MESSAGE.INVALID_IMAGE_TYPE } + ); + export const ProductFormSchema = z.object({ images: z - .array(z.union([z.instanceof(File), z.string()])) - .min(1, { message: MESSAGE.PRODUCT_IMAGE_REQUIRED }) - .max(1, { message: MESSAGE.INVALID_IMAGE_COUNT }) - .refine( - (files) => { - if (files[0] instanceof File) { - return files[0].size <= MAX_FILE_SIZE; - } - return true; // string일 경우는 size 체크 스킵 - }, - { - message: `${MESSAGE.INVALID_IMAGE_SIZE} (${ - MAX_FILE_SIZE / 1024 / 1024 - }MB 초과)`, - } - ) - .refine( - (files) => { - if (files[0] instanceof File) { - return ACCEPT_FILE_TYPES.includes(files[0].type); - } - return true; // string일 경우는 type 체크 스킵 - }, - { message: MESSAGE.INVALID_IMAGE_TYPE } - ), + .array(z.union([imageFile, z.string()])) + .min(1, { message: MESSAGE.PRODUCT_IMAGE_REQUIRED }), name: z.string().nonempty({ message: MESSAGE.PRODUCT_NAME_REQUIRED }), description: z.string().nonempty({ message: MESSAGE.PRODUCT_DESCRIPTION_REQUIRED, diff --git a/src/schemas/user.ts b/src/schemas/user.ts new file mode 100644 index 000000000..8ff57fd19 --- /dev/null +++ b/src/schemas/user.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { AUTH_VALIDATION_MESSAGES as MESSAGE } from "@constants/message"; +import { ACCEPT_FILE_TYPES, MAX_FILE_SIZE } from "@/constants/file"; + +export const changePasswordFormSchema = z + .object({ + password: z + .string() + .nonempty({ message: MESSAGE.PASSWORD_REQUIRED }) + .min(8, { message: MESSAGE.PASSWORD_MIN_LENGTH }), + newPassword: z + .string() + .nonempty({ message: MESSAGE.PASSWORD_REQUIRED }) + .min(8, { message: MESSAGE.PASSWORD_MIN_LENGTH }), + newPasswordConfirmation: z + .string() + .nonempty({ message: MESSAGE.PASSWORD_REQUIRED }) + .min(8, { message: MESSAGE.PASSWORD_MIN_LENGTH }), + }) + .refine((data) => data.newPassword === data.newPasswordConfirmation, { + path: ["newPasswordConfirmation"], + message: MESSAGE.PASSWORD_MISMATCH, + }); + +export type ChangePasswordFormType = z.infer; + +const imageFile = z + .instanceof(File) + .refine( + (file) => { + return file.size <= MAX_FILE_SIZE; + }, + { + message: `${MESSAGE.INVALID_IMAGE_SIZE} (${ + MAX_FILE_SIZE / 1024 / 1024 + }MB 초과)`, + } + ) + .refine( + (file) => { + return ACCEPT_FILE_TYPES.includes(file.type); + }, + { message: MESSAGE.INVALID_IMAGE_TYPE } + ); + +export const editProfileFormSchmea = z.object({ + image: z.union([imageFile, z.string()]).optional(), +}); + +export type EditProfileFormType = z.infer; diff --git a/src/service/auth.ts b/src/service/auth.ts index e2584c257..f917d54a1 100644 --- a/src/service/auth.ts +++ b/src/service/auth.ts @@ -1,6 +1,6 @@ import { SigninFormType, SignupFormType } from "@schemas/auth"; import { axiosInstance } from "@service/axios"; -import { RefreshResponse, AuthResponse, User } from "@type/auth"; +import { RefreshResponse, AuthResponse } from "@type/auth"; import axios from "axios"; export async function login({ email, password }: SigninFormType) { @@ -28,12 +28,6 @@ export async function signUp({ return response.data; } -export async function getUser() { - const response = await axiosInstance.get("/users/me"); - - return response.data; -} - export async function refreshAccessToken(refreshToken: string) { const response = await axios.post( `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, diff --git a/src/service/axios.ts b/src/service/axios.ts index 81c8b465a..977149157 100644 --- a/src/service/axios.ts +++ b/src/service/axios.ts @@ -1,5 +1,66 @@ +import { auth, update } from "@/auth"; import axios from "axios"; +import { getSession } from "next-auth/react"; +import { refreshAccessToken } from "./auth"; export const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, }); + +axiosInstance.interceptors.request.use( + async (config) => { + const session = + typeof window === "undefined" ? await auth() : await getSession(); + const accessToken = session?.accessToken; + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +axiosInstance.interceptors.response.use( + function (response) { + return response; + }, + async function (error) { + if (error.response?.status === 401 && !error.config._retry) { + try { + error.config._retry = true; + + const session = + typeof window === "undefined" ? await auth() : await getSession(); + + const refreshToken = session?.refreshToken; + if (refreshToken) { + const accessToken = await handleRrefreshToken(session.refreshToken); + + if (accessToken !== session.accessToken) { + await update({ accessToken }); + error.config.headers.Authorization = `Bearer ${accessToken}`; + } + } + return axiosInstance(error.config); + } catch (refreshError) { + console.error("refresh error", refreshError); + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); + +async function handleRrefreshToken(refreshToken: string) { + try { + const { accessToken } = await refreshAccessToken(refreshToken); + return accessToken; + } catch (err) { + throw err; + } +} diff --git a/src/service/serverAxios.ts b/src/service/serverAxios.ts deleted file mode 100644 index 01aff8aae..000000000 --- a/src/service/serverAxios.ts +++ /dev/null @@ -1,19 +0,0 @@ -"use server"; - -import { auth } from "@/auth"; -import { axiosInstance } from "./axios"; - -export async function initServerInterceptor() { - axiosInstance.interceptors.request.use( - async (config) => { - const session = await auth(); - if (session?.accessToken) { - config.headers.Authorization = `Bearer ${session?.accessToken}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - } - ); -} diff --git a/src/service/user.ts b/src/service/user.ts new file mode 100644 index 000000000..d14a5a7cf --- /dev/null +++ b/src/service/user.ts @@ -0,0 +1,65 @@ +import { User } from "@/types/auth"; +import { axiosInstance } from "./axios"; +import { Product } from "@/types/product"; +import { PaginationResponse } from "@/types/common"; +import { Article, ImageUploadResponse } from "@/types/article"; + +export async function getUser() { + const response = await axiosInstance.get("/users/me"); + + return response.data; +} + +export async function getUserActivity() { + const [productsResponse, favoritesResponse] = await Promise.all([ + axiosInstance.get, "totalCount">>( + "/users/me/products" + ), + axiosInstance.get< + Pick, "totalCount"> + >("/users/me/favorites"), + ]); + + return { + products: productsResponse.data, + favorites: favoritesResponse.data, + }; +} + +export async function changeUserPassword({ + password, + newPassword, + newPasswordConfirmation, +}: { + password: string; + newPassword: string; + newPasswordConfirmation: string; +}) { + const response = await axiosInstance.patch("/users/me/password", { + passwordConfirmation: newPasswordConfirmation, + password: newPassword, + currentPassword: password, + }); + + return response.data; +} + +export async function uploadProfileImage(file: File) { + const imgFormData = new FormData(); + imgFormData.append("image", file); + + const response = await axiosInstance.post( + "/images/upload", + imgFormData + ); + + return response.data; +} + +export async function editProfileImage(image: string) { + const response = await axiosInstance.patch("/users/me", { + image, + }); + + return response.data; +} diff --git a/src/util/getQueryClient.ts b/src/util/getQueryClient.ts new file mode 100644 index 000000000..4602496c0 --- /dev/null +++ b/src/util/getQueryClient.ts @@ -0,0 +1,37 @@ +import { + QueryClient, + defaultShouldDehydrateQuery, + isServer, +} from "@tanstack/react-query"; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + dehydrate: { + // include pending queries in dehydration + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === "pending", + }, + }, + }); +} + +let browserQueryClient: QueryClient | undefined = undefined; + +export function getQueryClient() { + if (isServer) { + // Server: always make a new query client + return makeQueryClient(); + } else { + // Browser: make a new query client if we don't already have one + // This is very important, so we don't re-make a new client if React + // suspends during the initial render. This may not be needed if we + // have a suspense boundary BELOW the creation of the query client + if (!browserQueryClient) browserQueryClient = makeQueryClient(); + return browserQueryClient; + } +}