diff --git a/public/icons/eye_close.svg b/public/icons/eye_close.svg new file mode 100644 index 00000000..42dc3836 --- /dev/null +++ b/public/icons/eye_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/eye_open.svg b/public/icons/eye_open.svg new file mode 100644 index 00000000..f47ec2df --- /dev/null +++ b/public/icons/eye_open.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/google.svg b/public/icons/google.svg new file mode 100644 index 00000000..655adf6c --- /dev/null +++ b/public/icons/google.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/icons/kakaotalk.svg b/public/icons/kakaotalk.svg new file mode 100644 index 00000000..96907010 --- /dev/null +++ b/public/icons/kakaotalk.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/apis/apis.ts b/src/apis/apis.ts index f7dd1088..d40c4adc 100644 --- a/src/apis/apis.ts +++ b/src/apis/apis.ts @@ -1,9 +1,19 @@ -import { StringObj, GetArticlesRes, GetArticlesParams } from "./apis.type"; +import { + StringObj, + GetArticlesRes, + GetArticlesParams, + PostSignUpParams, + PostSignUpRes, + PostLogInParams, + PostLogInRes, +} from "./apis.type"; const BASE_URL = "https://panda-market-api.vercel.app/"; const PATH = { ARTICLE: "articles", + SIGNUP: "auth/signUp", + LOGIN: "auth/signIn", }; async function processResponse(response: Response) { @@ -30,3 +40,49 @@ export async function getArticles({ const response = await fetch(url); return processResponse(response); } + +export async function postSignUp({ + email, + nickname, + password, + passwordConfirmation, +}: PostSignUpParams): Promise { + const url = new URL(PATH.SIGNUP, BASE_URL); + const headObj = new Headers({ + "Content-Type": "application/json", + }); + const bodyObj: StringObj = { + email, + nickname, + password, + passwordConfirmation, + }; + + const response = await fetch(url, { + method: "post", + headers: headObj, + body: JSON.stringify(bodyObj), + }); + return processResponse(response); +} + +export async function postLogIn({ + email, + password, +}: PostLogInParams): Promise { + const url = new URL(PATH.LOGIN, BASE_URL); + const headObj = new Headers({ + "Content-Type": "application/json", + }); + const bodyObj: StringObj = { + email, + password, + }; + + const response = await fetch(url, { + method: "post", + headers: headObj, + body: JSON.stringify(bodyObj), + }); + return processResponse(response); +} diff --git a/src/apis/apis.type.ts b/src/apis/apis.type.ts index b928b8b1..dae148a9 100644 --- a/src/apis/apis.type.ts +++ b/src/apis/apis.type.ts @@ -27,3 +27,30 @@ export interface ArticleProps { } export type GetArticlesParams = GetListParams; export type GetArticlesRes = GetListRes; + +interface UserProps { + user: { + id: number; + nickname: string; + image: string | null; + createdAt: string; + updatedAt: string; + email: string; + }; + accessToken: string; + refreshToken: string; +} + +export interface PostSignUpParams { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; +} +export type PostSignUpRes = UserProps; + +export interface PostLogInParams { + email: string; + password: string; +} +export type PostLogInRes = UserProps; diff --git a/src/components/layout/Header.module.css b/src/components/layout/Header.module.css index 3062c32d..c39e4403 100644 --- a/src/components/layout/Header.module.css +++ b/src/components/layout/Header.module.css @@ -62,3 +62,16 @@ .profile { width: 40px; } + +.logInButton { + display: flex; + align-items: center; + justify-content: center; + width: 128px; + height: 48px; + border-radius: 8px; + background-color: var(--blue-100); + font-size: var(--size-lg); + font-weight: var(--semibold); + color: var(--gray-100); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 5bbd57c2..8b645f44 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; @@ -6,6 +7,10 @@ import styles from "./Header.module.css"; export default function Header() { const path = useRouter().pathname; + const [token, setToken] = useState(null); + useEffect(() => { + setToken(window.localStorage.getItem("accessToken")); + }, []); return (
@@ -30,7 +35,17 @@ export default function Header() { - 프로필 사진 + {token ? ( + 프로필 사진 + ) : ( + + 로그인 + + )}
); } diff --git a/src/components/pages/account/LogInForm.module.css b/src/components/pages/account/LogInForm.module.css new file mode 100644 index 00000000..58312de9 --- /dev/null +++ b/src/components/pages/account/LogInForm.module.css @@ -0,0 +1,87 @@ +.form { + display: flex; + flex-direction: column; +} + +.formField { + position: relative; + display: flex; + flex-direction: column; +} + +.label { + margin-bottom: 16px; + font-size: var(--size-2lg); + font-weight: bold; +} +@media (max-width: 767px) { + .label { + margin-bottom: 8px; + font-size: var(--size-md); + } +} + +.input { + display: block; + width: 100%; + padding: 16px 24px; + border-radius: 12px; + background-color: var(--gray-100); + border: 2px solid #00000000; + outline: none; + font-size: var(--size-lg); +} +@media (max-width: 767px) { + .input { + font-size: var(--size-md); + } +} + +.input::placeholder { + color: var(--gray-400); +} + +.input:focus { + border: 2px solid var(--blue-100); +} + +.input_error { + border: 2px solid var(--red); +} + +.toggleButton { + position: absolute; + top: calc(43px + (60px / 2) - (24px / 2)); + right: 24px; +} +@media (max-width: 767px) { + .toggleButton { + top: calc(29px + (60px / 2) - (24px / 2)); + } +} + +.error { + height: 27px; + padding: 3px 16px; + font-size: var(--size-md); + color: white; +} + +.error_active { + color: var(--red); +} + +.submitButton { + margin-top: 8px; + padding: 13px 0; + border-radius: 9999px; + background-color: var(--blue-100); + text-align: center; + font-size: var(--size-xl); + font-weight: 500; + color: var(--gray-100); +} + +.submitButton:disabled { + background-color: var(--gray-400); +} diff --git a/src/components/pages/account/LogInForm.tsx b/src/components/pages/account/LogInForm.tsx new file mode 100644 index 00000000..4e4fcf79 --- /dev/null +++ b/src/components/pages/account/LogInForm.tsx @@ -0,0 +1,83 @@ +import { ChangeEvent, FormEvent, useState } from "react"; +import { useRouter } from "next/router"; +import Image from "next/image"; +import { PostLogInParams, PostLogInRes } from "@/apis/apis.type"; +import { useQuery } from "@/hooks/useQuery"; +import { postLogIn } from "@/apis/apis"; +import closedEye from "#/icons/eye_close.svg"; +import styles from "./LogInForm.module.css"; + +export default function LogInForm() { + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + const { error, data, query } = useQuery( + postLogIn + ); + const { replace } = useRouter(); + + const handleFormSubmit = async (e: FormEvent) => { + e.preventDefault(); + await query(formData); + + if (error || !data) { + alert("로그인에 실패했습니다.\n다시 시도해 주세요!"); + return; + } + + window.localStorage.setItem("accessToken", data.accessToken); + window.localStorage.setItem("refreshToken", data.refreshToken); + alert("로그인에 성공했습니다.\n즐거운 판다마켓 되세요!"); + replace("/"); + }; + + const handleInputChange = (e: ChangeEvent) => { + setFormData((prevData) => { + const nextData = { ...prevData }; + nextData[e.target.name as keyof PostLogInParams] = e.target.value; + return nextData; + }); + }; + + return ( +
+
+ + + +
+
+ + + + +
+ +
+ ); +} diff --git a/src/components/pages/account/SignUpForm.module.css b/src/components/pages/account/SignUpForm.module.css new file mode 100644 index 00000000..58312de9 --- /dev/null +++ b/src/components/pages/account/SignUpForm.module.css @@ -0,0 +1,87 @@ +.form { + display: flex; + flex-direction: column; +} + +.formField { + position: relative; + display: flex; + flex-direction: column; +} + +.label { + margin-bottom: 16px; + font-size: var(--size-2lg); + font-weight: bold; +} +@media (max-width: 767px) { + .label { + margin-bottom: 8px; + font-size: var(--size-md); + } +} + +.input { + display: block; + width: 100%; + padding: 16px 24px; + border-radius: 12px; + background-color: var(--gray-100); + border: 2px solid #00000000; + outline: none; + font-size: var(--size-lg); +} +@media (max-width: 767px) { + .input { + font-size: var(--size-md); + } +} + +.input::placeholder { + color: var(--gray-400); +} + +.input:focus { + border: 2px solid var(--blue-100); +} + +.input_error { + border: 2px solid var(--red); +} + +.toggleButton { + position: absolute; + top: calc(43px + (60px / 2) - (24px / 2)); + right: 24px; +} +@media (max-width: 767px) { + .toggleButton { + top: calc(29px + (60px / 2) - (24px / 2)); + } +} + +.error { + height: 27px; + padding: 3px 16px; + font-size: var(--size-md); + color: white; +} + +.error_active { + color: var(--red); +} + +.submitButton { + margin-top: 8px; + padding: 13px 0; + border-radius: 9999px; + background-color: var(--blue-100); + text-align: center; + font-size: var(--size-xl); + font-weight: 500; + color: var(--gray-100); +} + +.submitButton:disabled { + background-color: var(--gray-400); +} diff --git a/src/components/pages/account/SignUpForm.tsx b/src/components/pages/account/SignUpForm.tsx new file mode 100644 index 00000000..a2f36b7f --- /dev/null +++ b/src/components/pages/account/SignUpForm.tsx @@ -0,0 +1,116 @@ +import { ChangeEvent, FormEvent, useState } from "react"; +import { useRouter } from "next/router"; +import Image from "next/image"; +import { PostSignUpParams, PostSignUpRes } from "@/apis/apis.type"; +import { useQuery } from "@/hooks/useQuery"; +import { postSignUp } from "@/apis/apis"; +import closedEye from "#/icons/eye_close.svg"; +import styles from "./SignUpForm.module.css"; + +export default function SignUpForm() { + const [formData, setFormData] = useState({ + email: "", + nickname: "", + password: "", + passwordConfirmation: "", + }); + const { error, data, query } = useQuery( + postSignUp + ); + const { replace } = useRouter(); + + const handleFormSubmit = async (e: FormEvent) => { + e.preventDefault(); + query(formData); + + if (error || !data) { + alert("회원가입에 실패했습니다.\n다시 시도해 주세요!"); + return; + } + + alert("회원가입에 성공했습니다.\n로그인 후 이용해 주세요!"); + replace("/login"); + }; + + const handleInputChange = (e: ChangeEvent) => { + setFormData((prevData) => { + const nextData = { ...prevData }; + nextData[e.target.name as keyof PostSignUpParams] = e.target.value; + return nextData; + }); + }; + + return ( +
+
+ + + +
+
+ + + +
+
+ + + + +
+
+ + + + +
+ +
+ ); +} diff --git a/src/components/pages/account/SocialLogIn.module.css b/src/components/pages/account/SocialLogIn.module.css new file mode 100644 index 00000000..c1279301 --- /dev/null +++ b/src/components/pages/account/SocialLogIn.module.css @@ -0,0 +1,24 @@ +.container { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 23px; + border-radius: 8px; + background-color: var(--blue-50); + font-size: var(--size-lg); +} + +.socialList { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.social { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; +} diff --git a/src/components/pages/account/SocialLogIn.tsx b/src/components/pages/account/SocialLogIn.tsx new file mode 100644 index 00000000..7745fb46 --- /dev/null +++ b/src/components/pages/account/SocialLogIn.tsx @@ -0,0 +1,35 @@ +import Image from "next/image"; +import Link from "next/link"; +import googleLogo from "#/icons/google.svg"; +import kakaoLogo from "#/icons/kakaotalk.svg"; +import styles from "./SocialLogIn.module.css"; + +export default function SocialLogIn() { + return ( +
+ 간편 로그인하기 +
    +
  • + + 구글 간편 로그인하기 + +
  • +
  • + + 카카오톡 간편 로그인하기 + +
  • +
+
+ ); +} diff --git a/src/components/pages/boards/BestItems.tsx b/src/components/pages/boards/BestItems.tsx index f44e3c17..ea439bd3 100644 --- a/src/components/pages/boards/BestItems.tsx +++ b/src/components/pages/boards/BestItems.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useMedia } from "@/hooks/useMedia"; +import { useMedia } from "@/store/MediaContext"; import { getArticles } from "@/apis/apis"; import { GetArticlesParams, GetArticlesRes } from "@/apis/apis.type"; import { useQuery } from "@/hooks/useQuery"; diff --git a/src/hooks/useMedia.ts b/src/hooks/useMedia.ts deleted file mode 100644 index 2d7b3459..00000000 --- a/src/hooks/useMedia.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from "react"; -import { MediaContext } from "@/store/MediaContext"; - -export function useMedia() { - const media = useContext(MediaContext); - return media; -} diff --git a/src/pages/login.module.css b/src/pages/login.module.css new file mode 100644 index 00000000..bd12af7e --- /dev/null +++ b/src/pages/login.module.css @@ -0,0 +1,57 @@ +.header { + display: flex; + flex-direction: column; + align-items: center; + margin: 36px 0; +} +@media (max-width: 767px) { + .header { + margin: 24px 0; + } +} + +.logo { + width: 396px; +} +@media (max-width: 767px) { + .logo { + width: 198px; + } +} + +.main { + display: flex; + flex-direction: column; + align-items: center; +} +@media (max-width: 767px) { + .main { + padding: 0 16px; + } +} + +.wrapper { + display: flex; + flex-direction: column; + gap: 24px; + width: 640px; +} +@media (max-width: 767px) { + .wrapper { + width: min(100%, 400px); + } +} + +.switch { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 48px; + font-size: var(--size-md); +} + +.switchLink { + text-decoration-line: underline; + color: var(--blue-100); +} diff --git a/src/pages/login.tsx b/src/pages/login.tsx new file mode 100644 index 00000000..1dfe0a21 --- /dev/null +++ b/src/pages/login.tsx @@ -0,0 +1,36 @@ +import Head from "next/head"; +import Link from "next/link"; +import Image from "next/image"; +import LogInForm from "@/components/pages/account/LogInForm"; +import SocialLogIn from "@/components/pages/account/SocialLogIn"; +import logo from "#/images/logo_w153x3.png"; +import styles from "./login.module.css"; + +export default function LogIn() { + return ( + <> + + 로그인 - 판다마켓 + +
+ + 홈페이지 바로가기 + +
+
+
+ + + + 판다마켓이 처음이신가요? + + 회원가입 + + +
+
+ + ); +} + +LogIn.isNotLayout = true; diff --git a/src/pages/signup.module.css b/src/pages/signup.module.css new file mode 100644 index 00000000..bd12af7e --- /dev/null +++ b/src/pages/signup.module.css @@ -0,0 +1,57 @@ +.header { + display: flex; + flex-direction: column; + align-items: center; + margin: 36px 0; +} +@media (max-width: 767px) { + .header { + margin: 24px 0; + } +} + +.logo { + width: 396px; +} +@media (max-width: 767px) { + .logo { + width: 198px; + } +} + +.main { + display: flex; + flex-direction: column; + align-items: center; +} +@media (max-width: 767px) { + .main { + padding: 0 16px; + } +} + +.wrapper { + display: flex; + flex-direction: column; + gap: 24px; + width: 640px; +} +@media (max-width: 767px) { + .wrapper { + width: min(100%, 400px); + } +} + +.switch { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 48px; + font-size: var(--size-md); +} + +.switchLink { + text-decoration-line: underline; + color: var(--blue-100); +} diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx new file mode 100644 index 00000000..38103f6c --- /dev/null +++ b/src/pages/signup.tsx @@ -0,0 +1,36 @@ +import Head from "next/head"; +import Link from "next/link"; +import Image from "next/image"; +import SignUpForm from "@/components/pages/account/SignUpForm"; +import SocialLogIn from "@/components/pages/account/SocialLogIn"; +import logo from "#/images/logo_w153x3.png"; +import styles from "./signup.module.css"; + +export default function SignUp() { + return ( + <> + + 회원가입 - 판다마켓 + +
+ + 홈페이지 바로가기 + +
+
+
+ + + + 이미 회원이신가요? + + 로그인 + + +
+
+ + ); +} + +SignUp.isNotLayout = true; diff --git a/src/store/MediaContext.tsx b/src/store/MediaContext.tsx index f27717d2..1a79f0ba 100644 --- a/src/store/MediaContext.tsx +++ b/src/store/MediaContext.tsx @@ -1,8 +1,15 @@ -import { createContext, useState, useEffect, ReactNode } from "react"; +import { + createContext, + useState, + useCallback, + useEffect, + useContext, + ReactNode, +} from "react"; export type MediaType = "PC" | "TABLET" | "MOBILE"; -export const MediaContext = createContext(undefined); +const MediaContext = createContext(undefined); interface MediaProviderProps { children: ReactNode; @@ -11,26 +18,31 @@ interface MediaProviderProps { export function MediaProvider({ children }: MediaProviderProps) { const [media, setMedia] = useState(); - useEffect(() => { - const checkMedia = (width: number): MediaType => { - if (width >= 1200) return "PC"; - if (width >= 768) return "TABLET"; - return "MOBILE"; - }; + const checkMedia = (width: number): MediaType => { + if (width >= 1200) return "PC"; + if (width >= 768) return "TABLET"; + return "MOBILE"; + }; - const handleWindowResize = () => { - setMedia(checkMedia(window.innerWidth)); - }; + const handleWindowResize = useCallback(() => { + setMedia(checkMedia(window.innerWidth)); + }, []); + useEffect(() => { handleWindowResize(); window.addEventListener("resize", handleWindowResize); return () => { window.removeEventListener("resize", handleWindowResize); }; - }, []); + }, [handleWindowResize]); return ( {children} ); } + +export function useMedia() { + const media = useContext(MediaContext); + return media; +}