diff --git a/8-sprint-fe/next.config.mjs b/8-sprint-fe/next.config.mjs index 4678774e6..bb38a264d 100644 --- a/8-sprint-fe/next.config.mjs +++ b/8-sprint-fe/next.config.mjs @@ -1,4 +1,21 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "sprint-fe-project.s3.ap-northeast-2.amazonaws.com", + }, + { + protocol: "https", + hostname: "st.depositphotos.com", + }, + { + protocol: "https", + hostname: "i.pinimg.com", + }, + ], + }, +}; export default nextConfig; diff --git a/8-sprint-fe/public/btn_visibility_off_24px.svg b/8-sprint-fe/public/btn_visibility_off_24px.svg new file mode 100644 index 000000000..2b7b3b1c4 --- /dev/null +++ b/8-sprint-fe/public/btn_visibility_off_24px.svg @@ -0,0 +1,3 @@ + + + diff --git a/8-sprint-fe/public/btn_visibility_on_24px.svg b/8-sprint-fe/public/btn_visibility_on_24px.svg new file mode 100644 index 000000000..35a75305e --- /dev/null +++ b/8-sprint-fe/public/btn_visibility_on_24px.svg @@ -0,0 +1,3 @@ + + + diff --git a/8-sprint-fe/public/ic_google.svg b/8-sprint-fe/public/ic_google.svg new file mode 100644 index 000000000..237b3a678 --- /dev/null +++ b/8-sprint-fe/public/ic_google.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/8-sprint-fe/public/ic_kakao.svg b/8-sprint-fe/public/ic_kakao.svg new file mode 100644 index 000000000..dfed416d0 --- /dev/null +++ b/8-sprint-fe/public/ic_kakao.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/8-sprint-fe/src/api/fetchClient.js b/8-sprint-fe/src/api/fetchClient.js new file mode 100644 index 000000000..694ed04b1 --- /dev/null +++ b/8-sprint-fe/src/api/fetchClient.js @@ -0,0 +1,70 @@ +const URL = `${process.env.NEXT_PUBLIC_CODEIT_URL}`; + +// 회원가입 +export async function fetchSignUp({ + email, + nickname, + password, + passwordConfirmation, +}) { + const response = await fetch(`${URL}/auth/signUp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + cache: "force-cache", + body: JSON.stringify({ + email, + nickname, + password, + passwordConfirmation, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); // JSON이 아닐 수도 있으니 text로 + throw new Error(errorText); + } + + return await response.json(); +} + +// 로그인 +export async function fetchLogin({ email, password }) { + const response = await fetch(`${URL}/auth/signIn`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + cache: "force-cache", + body: JSON.stringify({ + email, + password, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error("로그인에 실패했습니다."); + } + + return await response.json(); +} + +// get me +export async function fetchGetMe(token) { + const response = await fetch(`${URL}/users/me`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error("로그인에 실패했습니다."); + } + + return await response.json(); +} diff --git a/8-sprint-fe/src/api/fetchProducts.js b/8-sprint-fe/src/api/fetchProducts.js new file mode 100644 index 000000000..5793bf1e8 --- /dev/null +++ b/8-sprint-fe/src/api/fetchProducts.js @@ -0,0 +1,25 @@ +const URL = `${process.env.NEXT_PUBLIC_CODEIT_URL}/products`; + +// products list get +export const fetchProducts = async ({ + page = 1, + pageSize = 10, + orderBy = "recent", +}) => { + const response = await fetch( + `${URL}?page=${page}&pageSize=${pageSize}&orderBy=${orderBy}` + ); + if (!response.ok) { + throw new Error("서버에서 데이터를 가져오는데 실패했습니다."); + } + return await response.json(); +}; + +// product get +export const fetchProduct = async ({ id }) => { + const response = await fetch(`${URL}/${id}`); + if (!response.ok) { + throw new Error("서버에서 데이터를 가져오는데 실패했습니다."); + } + return await response.json(); +}; diff --git a/8-sprint-fe/src/api/userService.js b/8-sprint-fe/src/api/userService.js new file mode 100644 index 000000000..aa9dfc236 --- /dev/null +++ b/8-sprint-fe/src/api/userService.js @@ -0,0 +1,50 @@ +import { cookieFetch } from "@/lib/fetchClient"; + +// FormData 전용 fetch 함수 (Content-Type 헤더 없음) +const formDataFetch = async (url, options = {}) => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const defaultOptions = { + // 쿠키 전송을 위한 설정 + credentials: "include", + // 서버 컴포넌트에서도 매번 재검증 + cache: "no-store", + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + }; + + const response = await fetch(`${baseURL}${url}`, mergedOptions); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + try { + return await response.json(); + } catch (e) { + return { status: response.status, ok: response.ok }; + } +}; + +export const userService = { + // 사용자 정보 요청 + getMe: () => cookieFetch("/users/me"), + + // 사용자 링크 요청 + getMyLinks: () => cookieFetch("/users/me/links"), + + // 사용자 정보 업데이트 (multipart/form-data) + updateMe: (formData) => + formDataFetch("/users/me", { + method: "PATCH", + body: formData, + }), + + // 링크 삭제 + deleteLink: (linkId) => + cookieFetch(`/users/me/links/${linkId}`, { + method: "DELETE", + }), +}; diff --git a/8-sprint-fe/src/app/(auth)/_components/LoginPage.jsx b/8-sprint-fe/src/app/(auth)/_components/LoginPage.jsx new file mode 100644 index 000000000..59d34864a --- /dev/null +++ b/8-sprint-fe/src/app/(auth)/_components/LoginPage.jsx @@ -0,0 +1,108 @@ +"use client"; +import { AuthLogo } from "@/app/(components)/atoms/Logo"; +import OAuth from "@/app/(components)/atoms/OAuth"; +import PasswordInput from "@/app/(components)/atoms/PasswordInput"; +import TextInput from "@/app/(components)/atoms/TextInput"; +import Link from "next/link"; +import React, { useState } from "react"; +import { validateEmail, validatePassword } from "./validator"; +import { useAuth } from "@/providers/AuthProvider"; +import { useRouter } from "next/navigation"; + +const LoginPage = () => { + const [value, setValue] = useState({ + email: "", + password: "", + }); + const [errors, setErrors] = useState({ email: "", password: "" }); + const { login } = useAuth(); + const router = useRouter(); + + const handleEmailChange = (e) => { + const val = e.target.value; + setValue((prev) => ({ + ...prev, + email: val, + })); + setErrors((prev) => ({ ...prev, email: validateEmail(val) })); + }; + + const handlePasswordChange = (e) => { + const val = e.target.value; + setValue((prev) => ({ + ...prev, + password: val, + })); + setErrors((prev) => ({ ...prev, password: validatePassword(val) })); + }; + + // 버튼 활성화 조건 + const isFormValid = + value.email !== "" && + value.password !== "" && + !errors.email && + !errors.password; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!isFormValid) { + console.log("invalidate"); + return; + } + + try { + await login(value); + + alert("로그인에 성공했습니다."); + router.push("/items"); + } catch (error) { + alert(error.message || "로그인에 실패했습니다."); + } + }; + + return ( + <> + + + +
+ + + + +
+ 판다마켓은 처음이신가요? + + 회원가입 + +
+ + + ); +}; + +export default LoginPage; diff --git a/8-sprint-fe/src/app/(auth)/_components/SignUpPage.jsx b/8-sprint-fe/src/app/(auth)/_components/SignUpPage.jsx new file mode 100644 index 000000000..a925dace4 --- /dev/null +++ b/8-sprint-fe/src/app/(auth)/_components/SignUpPage.jsx @@ -0,0 +1,167 @@ +"use client"; +import { AuthLogo } from "@/app/(components)/atoms/Logo"; +import OAuth from "@/app/(components)/atoms/OAuth"; +import PasswordInput from "@/app/(components)/atoms/PasswordInput"; +import TextInput from "@/app/(components)/atoms/TextInput"; +import Link from "next/link"; +import React, { useState } from "react"; +import { + validateEmail, + validateNickname, + validatePassword, + validatePasswordChecker, +} from "./validator"; +import { useAuth } from "@/providers/AuthProvider"; +import { useRouter } from "next/navigation"; + +const SignUpPage = () => { + const [user, setUser] = useState({ + email: "", + nickname: "", + password: "", + passwordConfirmation: "", + }); + const [errors, setErrors] = useState({ + email: "", + nickname: "", + password: "", + passwordConfirmation: "", + }); + const { signUp } = useAuth(); + const router = useRouter(); + + const handleEmailChange = (e) => { + const value = e.target.value; + setUser((prev) => ({ + ...prev, + email: value, + })); + setErrors((prev) => ({ ...prev, email: validateEmail(value) })); + }; + + const handleNicknameChange = (e) => { + const value = e.target.value; + setUser((prev) => ({ + ...prev, + nickname: value, + })); + setErrors((prev) => ({ ...prev, nickname: validateNickname(value) })); + }; + + const handlePasswordChange = (e) => { + const value = e.target.value; + setUser((prev) => ({ + ...prev, + password: value, + })); + setErrors((prev) => ({ ...prev, password: validatePassword(value) })); + }; + + const handlePasswordCheckerChange = (e) => { + const value = e.target.value; + setUser((prev) => ({ + ...prev, + passwordConfirmation: value, + })); + setErrors((prev) => ({ + ...prev, + passwordConfirmation: validatePasswordChecker(value, user.password), + })); + }; + + // 버튼 활성화 조건 + const isFormValid = + user.email !== "" && + user.password !== "" && + user.nickname !== "" && + user.passwordConfirmation !== "" && + !errors.email && + !errors.password && + !errors.nickname && + !errors.passwordConfirmation; + + // 폼 제출 + const handleSubmit = async (e) => { + e.preventDefault(); // 페이지 새로고침 방지 + + if (!isFormValid) { + console.log("invalidate"); + return; + } + + try { + await signUp(user); + + // 회원가입 성공 후 처리 + alert("회원가입에 성공했습니다."); + router.push("/items"); + } catch (error) { + alert(error.message || "회원가입에 실패했습니다."); + } + }; + + return ( + <> + + + +
+ + + + + + +
+ 이미 회원이신가요? + + 로그인 + +
+ + + ); +}; + +export default SignUpPage; diff --git a/8-sprint-fe/src/app/(auth)/_components/validator.js b/8-sprint-fe/src/app/(auth)/_components/validator.js new file mode 100644 index 000000000..f4027e7c0 --- /dev/null +++ b/8-sprint-fe/src/app/(auth)/_components/validator.js @@ -0,0 +1,44 @@ +const emailPattern = /^[A-Za-z0-9_\.\-]+@[A-Za-z0-9\-]+\.[A-za-z0-9\-]+/; // 이메일 패턴 +const MIN_PASSWORD_LENGTH = 8; // 비밀번호 최소길이 + +export function validateEmail(val) { + if (!val) return "이메일을 입력해주세요."; + if (!emailPattern.test(val)) return "잘못된 이메일 형식입니다."; + return ""; +} + +export function validatePassword(val) { + if (!val) return "비밀번호를 입력해주세요."; + if (val.length < MIN_PASSWORD_LENGTH) + return "비밀번호는 8자 이상 입력해주세요."; + return ""; +} + +export function validateNickname(val) { + if (!val) return "닉네임을 입력해주세요."; + return ""; +} + +export function validatePasswordChecker(val, checker) { + if (!val) return "비밀번호가 일치하지 않습니다."; + if (val !== checker) + return "비밀번호가 일치하지 않습니다."; + return ""; +} + +export function validateProductName(val) { + if (!val || val.length > 10) return "10자 이내로 입력해주세요."; + return ""; +} +export function validateDescription(val) { + if (!val || val.length < 10) return "10자 이상 입력해주세요."; + return ""; +} +export function validatePrice(val) { + if (!val || Number.isFinite(val)) return "숫자로 입력해주세요."; + return ""; +} +export function validateTag (val) { + if (val.length > 5) return "5자 이내롤 입력해주세요."; + return ""; +} \ No newline at end of file diff --git a/8-sprint-fe/src/app/(auth)/layout.jsx b/8-sprint-fe/src/app/(auth)/layout.jsx new file mode 100644 index 000000000..eacc5708a --- /dev/null +++ b/8-sprint-fe/src/app/(auth)/layout.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { AuthLayout } from "../(components)/Layout"; + +export default function Authlayout({ children }) { + return ( + +
{children}
+
+ ); +} diff --git a/8-sprint-fe/src/app/(auth)/login/page.jsx b/8-sprint-fe/src/app/(auth)/login/page.jsx new file mode 100644 index 000000000..b71ac2fb9 --- /dev/null +++ b/8-sprint-fe/src/app/(auth)/login/page.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import LoginPage from "../_components/LoginPage"; + +const Login = () => { + return ; +}; + +export default Login; diff --git a/8-sprint-fe/src/app/(auth)/signup/page.jsx b/8-sprint-fe/src/app/(auth)/signup/page.jsx new file mode 100644 index 000000000..ab73c4ff6 --- /dev/null +++ b/8-sprint-fe/src/app/(auth)/signup/page.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import SignUpPage from "../_components/SignUpPage"; + +const Register = () => { + return ; +}; + +export default Register; diff --git a/8-sprint-fe/src/app/(components)/Header.jsx b/8-sprint-fe/src/app/(components)/Header.jsx deleted file mode 100644 index c5f0d09c4..000000000 --- a/8-sprint-fe/src/app/(components)/Header.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import Link from "next/link"; -import React from "react"; -import Logo from "./atoms/Logo"; -import Button from "./atoms/Button"; - -const Header = () => { - return ( -
-
-
- - - - 자유게시판 - 중고마켓 -
- - - -
-
- ); -}; - -export default Header; diff --git a/8-sprint-fe/src/app/(components)/Layout.jsx b/8-sprint-fe/src/app/(components)/Layout.jsx new file mode 100644 index 000000000..1dd85b6fc --- /dev/null +++ b/8-sprint-fe/src/app/(components)/Layout.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import Nav from "./Nav"; +import Footer from "./Footer"; + +export function LandingLayout({ children }) { + return ( + <> +