diff --git a/components/addboard/AddBoardForm.tsx b/components/addboard/AddBoardForm.tsx index 6f280ee5a..a04170a02 100644 --- a/components/addboard/AddBoardForm.tsx +++ b/components/addboard/AddBoardForm.tsx @@ -1,12 +1,12 @@ import { ChangeEvent, useState, useEffect, MouseEvent } from "react"; import { useRouter } from "next/router"; +import useAuth from "@/hooks/useAuth"; import FileInput from "../ui/FileInput"; import Input from "../ui/Input"; import Textarea from "../ui/Textarea"; import Button from "../ui/Button"; import { fetchData } from "@/lib/fetchData"; import { ARTICLE_URL, IMAGE_URL } from "@/constants/url"; -import { useAuth } from "@/contexts/AuthProvider"; import styles from "./AddBoardForm.module.css"; interface Board { @@ -26,8 +26,8 @@ const INITIAL_BOARD: Board = { const AddBoardForm = () => { const [isDisabled, setIsDisabled] = useState(true); const [values, setValues] = useState(INITIAL_BOARD); - const { accessToken } = useAuth(); const router = useRouter(); + const { accessToken } = useAuth(true); const handleChange = (name: BoardField, value: Board[BoardField]): void => { setValues((prevValues) => { @@ -57,7 +57,7 @@ const AddBoardForm = () => { const formData = new FormData(); formData.append("image", imgFile); - const response = await fetchData(IMAGE_URL, { + const response = await fetchData>(IMAGE_URL, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, @@ -67,7 +67,7 @@ const AddBoardForm = () => { url = response.url; } - const { id } = await fetchData(ARTICLE_URL, { + const { id } = await fetchData>(ARTICLE_URL, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, diff --git a/components/auth/AuthForm.module.css b/components/auth/AuthForm.module.css new file mode 100644 index 000000000..649a0cbe4 --- /dev/null +++ b/components/auth/AuthForm.module.css @@ -0,0 +1,95 @@ +.authForm { + max-width: 400px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.input { + gap: 8px; +} + +.authForm label { + font-size: 0.875rem; + line-height: 1.5rem; +} + +.btnEye { + position: absolute; + width: 24px; + height: 24px; + top: 16px; + right: 16px; + cursor: pointer; +} + +.button { + padding: 12px 145px; + border-radius: 40px; + font-size: 1.25rem; + line-height: 2rem; + cursor: pointer; + margin-bottom: 8px; +} + +.button.active { + background-color: var(--blue); +} + +.validationFocus { + outline: 1px solid var(--red); +} + +.validationMessage { + display: none; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.5rem; + color: var(--red); + padding-left: 16px; + margin-top: 8px; +} + +.authLink { + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5rem; + text-align: center; + color: var(--gary800); +} + +.authLink > a { + color: var(--blue); + text-decoration: underline; + padding-left: 4px; +} + +@media screen and (min-width: 768px) { + .authForm { + max-width: 100%; + width: 640px; + gap: 24px; + } + + .input { + gap: 16px; + } + + .authForm label { + font-size: 1.125rem; + line-height: 1.625rem; + } + + .authForm .authInput { + padding: 16px 24px; + } + + .button { + padding: 16px 124px; + margin-bottom: 0; + } + + .authForm > .otherAccount { + margin-bottom: 0; + } +} diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx new file mode 100644 index 000000000..fdecb44b3 --- /dev/null +++ b/components/auth/LoginForm.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import useAuth from "@/hooks/useAuth"; +import FormInput from "../ui/FormInput"; +import SocialLogin from "./SocialLogin"; +import Button from "../ui/Button"; +import styles from "./AuthForm.module.css"; +import hideIcon from "@/public/btn_hide.svg"; +import showIcon from "@/public/btn_show.svg"; + +interface FormValues extends Record { + email: string; + password: string; +} + +const LoginForm = () => { + const [showPassword, setShowPassword] = useState(false); + const { + register, + handleSubmit, + formState: { errors, isValid }, + setError, + } = useForm({ mode: "onChange" }); + const router = useRouter(); + const { login } = useAuth(); + + const onSubmit = async (data: FormValues) => { + try { + await login(data); + router.push("/"); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message === "존재하지 않는 이메일입니다.") { + setError("email", { type: "manual", message: err.message }); + } + if (err.message === "비밀번호가 일치하지 않습니다.") { + setError("password", { type: "manual", message: err.message }); + } + } + } + }; + + const togglePasswordVisibility = () => { + setShowPassword(!showPassword); + }; + + return ( +
+ + + 비밀번호 표시 + + + + + 판다마켓이 처음이신가요? + 회원가입 + + + ); +}; + +export default LoginForm; diff --git a/components/auth/SignUpForm.tsx b/components/auth/SignUpForm.tsx new file mode 100644 index 000000000..f3634e34a --- /dev/null +++ b/components/auth/SignUpForm.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import useAuth from "@/hooks/useAuth"; +import FormInput from "../ui/FormInput"; +import SocialLogin from "./SocialLogin"; +import Button from "../ui/Button"; +import styles from "./AuthForm.module.css"; +import hideIcon from "@/public/btn_hide.svg"; +import showIcon from "@/public/btn_show.svg"; + +interface FormValues extends Record { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; +} + +const LoginForm = () => { + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirmation, setShowPasswordConfirmation] = + useState(false); + const { + register, + handleSubmit, + formState: { errors, isValid }, + watch, + setError, + trigger, + } = useForm({ mode: "onChange" }); + const router = useRouter(); + const { signup } = useAuth(); + const watchedPassword = watch("password"); + + const onSubmit = async (data: FormValues) => { + try { + await signup(data); + router.push("/login"); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message === "이미 사용중인 이메일입니다.") { + setError("email", { type: "manual", message: err.message }); + } + if (err.message === "이미 사용중인 닉네임입니다.") { + setError("nickname", { type: "manual", message: err.message }); + } + } + } + }; + + const togglePasswordVisibility = () => setShowPassword(!showPassword); + const togglePasswordConfirmationVisibility = () => + setShowPasswordConfirmation(!showPasswordConfirmation); + + useEffect(() => { + if (watchedPassword) { + trigger("passwordConfirmation"); + } + }, [watchedPassword, trigger]); + + return ( +
+ + + + 비밀번호 표시 + + + value === watchedPassword || "비밀번호가 일치하지 않습니다.", + required: (value) => value !== "" || "비밀번호 확인을 입력해주세요.", + }} + error={errors.passwordConfirmation} + > + 비밀번호 표시 + + + + + 이미 회원이신가요? + 로그인 + + + ); +}; + +export default LoginForm; diff --git a/components/auth/SocialLogin.module.css b/components/auth/SocialLogin.module.css new file mode 100644 index 000000000..b65ba4954 --- /dev/null +++ b/components/auth/SocialLogin.module.css @@ -0,0 +1,25 @@ +.socialLogin { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #e6f2ff; + padding: 16px 23px; + gap: 10px; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + line-height: 1.625rem; + color: var(--gary800); + margin-bottom: 8px; +} + +.socialLogin img { + display: block; + width: 42px; + height: 42px; +} + +.container { + display: flex; + gap: 16px; +} diff --git a/components/auth/SocialLogin.tsx b/components/auth/SocialLogin.tsx new file mode 100644 index 000000000..9fcf5b420 --- /dev/null +++ b/components/auth/SocialLogin.tsx @@ -0,0 +1,31 @@ +import Image from "next/image"; +import Container from "../layout/Container"; +import styles from "./SocialLogin.module.css"; +import kakaoIcon from "@/public/ic_kakao.svg"; +import googleIcon from "@/public/ic_google.svg"; + +const SocialLogin = () => { + return ( +
+ 간편 로그인하기 + + + 구글 로그인 + + + 카카오 로그인 + + +
+ ); +}; + +export default SocialLogin; diff --git a/components/board/Comments.tsx b/components/board/Comments.tsx index 8f84955b5..49f3b2699 100644 --- a/components/board/Comments.tsx +++ b/components/board/Comments.tsx @@ -6,7 +6,12 @@ import replyEmptyImg from "@/public/Img_reply_empty.svg"; import Image from "next/image"; import { CommentProps } from "@/types/articleTypes"; -const Comments = ({ comments }: { comments: CommentProps[] }) => { +interface CommentsProps { + comments: CommentProps[]; + onUpdate: (id: number | null, value: string) => void; +} + +const Comments = ({ comments, onUpdate }: CommentsProps) => { const [editingId, setEditingId] = useState(null); const handleSelect = (id: number, option: string) => { @@ -19,13 +24,23 @@ const Comments = ({ comments }: { comments: CommentProps[] }) => { setEditingId(null); }; + const handleUpdate = (value: string) => { + onUpdate(editingId, value); + setEditingId(null); + }; + return (
{comments.length ? (
    {comments.map(({ id, ...comment }: CommentProps) => id === editingId ? ( - + ) : ( { onCancel: VoidFunction; + onUpdate: (value: string) => void; } -const EditingComment = ({ content, createdAt, writer, onCancel }: Props) => { +const EditingComment = ({ + content, + createdAt, + writer, + onCancel, + onUpdate, +}: Props) => { const [isDisabled, setIsDisabled] = useState(false); + const [value, setValue] = useState(content); const handleTextareaChange = (e: ChangeEvent) => { - if (e.target.value !== "") { - setIsDisabled(false); - return; - } + const isEmpty = !e.target.value.trim(); + setValue(e.target.value); + setIsDisabled(isEmpty); + }; - setIsDisabled(true); + const handleSubmit = (e: MouseEvent) => { + e.preventDefault(); + onUpdate(value); }; return (
  • ); diff --git a/constants/url.ts b/constants/url.ts index abeb364d2..b55ba519d 100644 --- a/constants/url.ts +++ b/constants/url.ts @@ -3,3 +3,5 @@ export const IMAGE_URL = "https://panda-market-api.vercel.app/images/upload"; export const LOGIN_URL = "https://panda-market-api.vercel.app/auth/signIn"; export const REFRESH_URL = "https://panda-market-api.vercel.app/auth/refresh-token"; +export const SIGNUP_URL = "https://panda-market-api.vercel.app/auth/signUp"; +export const COMMENT_URL = "https://panda-market-api.vercel.app/comments"; diff --git a/contexts/AuthProvider.tsx b/contexts/AuthProvider.tsx deleted file mode 100644 index 3c1a7e40c..000000000 --- a/contexts/AuthProvider.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useState, -} from "react"; -import { fetchData } from "@/lib/fetchData"; -import { LOGIN_URL, REFRESH_URL } from "@/constants/url"; - -interface AuthContextType { - accessToken: string | null; - login: () => Promise; - logout: VoidFunction; -} - -const AuthContext = createContext(undefined); - -interface AuthProviderProps { - children: ReactNode; -} - -export const AuthProvider = ({ children }: AuthProviderProps) => { - const [accessToken, setAccessToken] = useState(null); - const [refreshToken, setRefreshToken] = useState(null); - - const refreshAccessToken = useCallback(async () => { - try { - const { accessToken } = await fetchData(REFRESH_URL, { - method: "POST", - body: { refreshToken }, - }); - setAccessToken(accessToken); - } catch (error) { - throw new Error(`Fetch failed: ${error}`); - } - }, [refreshToken]); - - const login = async () => { - try { - const { accessToken, refreshToken } = await fetchData(LOGIN_URL, { - method: "POST", - body: { email: "bonobono@email.com", password: "12341234" }, - }); - setAccessToken(accessToken); - setRefreshToken(refreshToken); - } catch (error) { - throw new Error(`Login failed: ${error}`); - } - }; - - const logout = () => { - setAccessToken(null); - setRefreshToken(null); - }; - - useEffect(() => { - const interval = setInterval(refreshAccessToken, 15 * 60 * 1000); - return () => clearInterval(interval); - }, [refreshAccessToken]); - - return ( - - {children} - - ); -}; - -export const useAuth = (): AuthContextType => { - const context = useContext(AuthContext); - if (!context) throw new Error("useAuth must be used within an AuthProvider"); - return context; -}; diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts new file mode 100644 index 000000000..fa33196cf --- /dev/null +++ b/hooks/useAuth.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { fetchData } from "@/lib/fetchData"; +import { LOGIN_URL, SIGNUP_URL } from "@/constants/url"; + +const getAccessToken = () => localStorage.getItem("accessToken"); +const getRefreshToken = () => localStorage.getItem("refreshToken"); + +const useAuth = (required: boolean = false) => { + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + const router = useRouter(); + + const login = async ({ email, password }: Record) => { + try { + const { accessToken, refreshToken } = await fetchData< + Record + >(LOGIN_URL, { + method: "POST", + body: { email, password }, + }); + + localStorage.setItem("accessToken", accessToken); + localStorage.setItem("refreshToken", refreshToken); + setAccessToken(accessToken); + setRefreshToken(refreshToken); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(error.message); + } + } + }; + + const logout = () => { + localStorage.removeItem("accessToken"); + localStorage.removeItem("refreshToken"); + setAccessToken(null); + setRefreshToken(null); + router.replace("/"); + }; + + const signup = async ({ + email, + nickname, + password, + passwordConfirmation, + }: Record) => { + try { + await fetchData(SIGNUP_URL, { + method: "POST", + body: { email, nickname, password, passwordConfirmation }, + }); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(error.message); + } + } + }; + + useEffect(() => { + setAccessToken(getAccessToken()); + setRefreshToken(getRefreshToken()); + }, []); + + useEffect(() => { + if (required && !getAccessToken()) { + router.replace("/login"); + } + }, [router, required]); + + return { accessToken, login, logout, signup }; +}; + +export default useAuth; diff --git a/lib/fetchData.ts b/lib/fetchData.ts index 7b09535c5..084f303ad 100644 --- a/lib/fetchData.ts +++ b/lib/fetchData.ts @@ -1,13 +1,39 @@ import { createURLSearchParams } from "./urlParams"; +import { REFRESH_URL } from "@/constants/url"; type FetchOptions = { query?: Record; - method?: "GET" | "POST" | "PUT" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record; body?: Record | string | FormData | null; }; -export const fetchData = async (url: string, options: FetchOptions = {}) => { +const refreshAccessToken = async () => { + const refreshToken = localStorage.getItem("refreshToken"); + + if (!refreshToken) return; + + const refreshResponse = await fetch(REFRESH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + + if (!refreshResponse.ok) { + throw new Error("Failed to refresh token"); + } + + const refreshData = await refreshResponse.json(); + localStorage.setItem("accessToken", refreshData.accessToken); + + return refreshData.accessToken; +}; + +export const fetchData = async ( + url: string, + options: FetchOptions = {}, + retry: boolean = false +): Promise => { try { let fullUrl = url; @@ -25,12 +51,11 @@ export const fetchData = async (url: string, options: FetchOptions = {}) => { : { "Content-Type": "application/json", ...headers }), }; - const requestBody = - method !== "GET" && body - ? isFormData - ? body - : JSON.stringify(body) - : null; + let requestBody = null; + + if (method !== "GET" && body) { + requestBody = isFormData ? body : JSON.stringify(body); + } const response = await fetch(fullUrl, { method, @@ -39,14 +64,32 @@ export const fetchData = async (url: string, options: FetchOptions = {}) => { }); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (response.status === 401 && retry === false) { + const newAccessToken = await refreshAccessToken(); + + return await fetchData( + url, + { + ...options, + headers: { + Authorization: `Bearer ${newAccessToken}`, + }, + }, + true + ); + } + + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); } const responseBody = await response.json(); return responseBody; } catch (error: unknown) { if (error instanceof Error) { - throw new Error(`Fetch failed: ${error.message}`); + throw new Error(error.message); } throw new Error("Unknown error occurred during fetch"); } diff --git a/package-lock.json b/package-lock.json index baa2b6655..c5f4bd7b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "13.5.6", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hook-form": "^7.53.1" }, "devDependencies": { "@types/node": "^20", @@ -2940,6 +2941,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.53.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz", + "integrity": "sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 1ce24924f..58156f1a0 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,17 @@ "lint": "next lint" }, "dependencies": { + "next": "13.5.6", "react": "^18", "react-dom": "^18", - "next": "13.5.6" + "react-hook-form": "^7.53.1" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", - "eslint-config-next": "13.5.6" + "eslint-config-next": "13.5.6", + "typescript": "^5" } } diff --git a/pages/_app.tsx b/pages/_app.tsx index 84406ebd7..3784809f8 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -2,20 +2,30 @@ import "@/styles/reset.css"; import "@/styles/variables.css"; import Head from "next/head"; import type { AppProps } from "next/app"; +import { useRouter } from "next/router"; import Header from "@/components/layout/Header"; import Container from "@/components/layout/Container"; -import { AuthProvider } from "@/contexts/AuthProvider"; +import styles from "@/styles/App.module.css"; export default function App({ Component, pageProps }: AppProps) { + const router = useRouter(); + + const isHideHeader = ["/login", "/signup"].includes(router.pathname); + const isHomePage = router.pathname === "/"; + return ( - + <> 판다 마켓 -
    - + {!isHideHeader &&
    } + {isHomePage ? ( - - + ) : ( + + + + )} + ); } diff --git a/pages/board/[id].tsx b/pages/board/[id].tsx index f24a1f201..151ecdb95 100644 --- a/pages/board/[id].tsx +++ b/pages/board/[id].tsx @@ -1,20 +1,14 @@ -import { - ChangeEvent, - FormEvent, - useCallback, - useEffect, - useState, -} from "react"; +import { FormEvent, MouseEvent, useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; import Link from "next/link"; +import useAuth from "@/hooks/useAuth"; import { GetServerSidePropsContext } from "next"; import BoardDetail from "@/components/board/BoardDetail"; import AddCommentForm from "@/components/board/AddCommentForm"; import Comments from "@/components/board/Comments"; import Button from "@/components/ui/Button"; -import { ARTICLE_URL } from "@/constants/url"; +import { ARTICLE_URL, COMMENT_URL } from "@/constants/url"; import { fetchData } from "@/lib/fetchData"; -import { useAuth } from "@/contexts/AuthProvider"; import { ArticleProps, CommentProps } from "@/types/articleTypes"; import styles from "@/styles/Board.module.css"; import Image from "next/image"; @@ -24,9 +18,12 @@ export const getServerSideProps = async ( context: GetServerSidePropsContext ) => { const id = context.params?.["id"]; - const { list } = await fetchData(`${ARTICLE_URL}/${id}/comments`, { - query: { limit: 5 }, - }); + const { list } = await fetchData>( + `${ARTICLE_URL}/${id}/comments`, + { + query: { limit: 5 }, + } + ); return { props: { @@ -40,16 +37,16 @@ const BoardDetailPage = ({ }: { comments: CommentProps[]; }) => { - const [board, setBoard] = useState(); + const [board, setBoard] = useState(undefined); const [comments, setComments] = useState(initialComments); const [comment, setComment] = useState(""); - const { accessToken } = useAuth(); + const { accessToken } = useAuth(true); const router = useRouter(); const { id } = router.query; const getBoard = useCallback(async () => { - const response = await fetchData(`${ARTICLE_URL}/${id}`, { + const response = await fetchData(`${ARTICLE_URL}/${id}`, { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -58,20 +55,42 @@ const BoardDetailPage = ({ }, [accessToken, id]); useEffect(() => { - getBoard(); - }, [getBoard]); + if (accessToken) { + getBoard(); + } + }, [accessToken, getBoard]); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - const newComment = await fetchData(`${ARTICLE_URL}/${id}/comments`, { - method: "POST", + const newComment = await fetchData( + `${ARTICLE_URL}/${id}/comments`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: { content: comment }, + } + ); + setComment(""); + setComments((prevComments) => [newComment, ...prevComments]); + }; + + const handleUpdate = async (id: number | null, content: string) => { + if (!id) return; + + const newComment = await fetchData(`${COMMENT_URL}/${id}`, { + method: "PATCH", headers: { Authorization: `Bearer ${accessToken}`, }, - body: { content: comment }, + body: { content }, }); - setComment(""); - setComments((prevComments) => [newComment, ...prevComments]); + setComments((prevComments) => + prevComments.map((comment) => + comment.id === newComment.id ? newComment : comment + ) + ); }; return ( @@ -82,7 +101,7 @@ const BoardDetailPage = ({ onChange={setComment} value={comment} /> - +