diff --git a/README.md b/README.md index f44d27ed4..f406ec124 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ **코드잇 12기 스프린트 내용입니다.** +## 11주차 스프린트 + +### 서버 액션 로직 간소화 +- fetch로만 로그인 로직을 처리하려고 하니, refresh 토큰을 이용하여 재 요청할 때 코드가 복잡하여 읽기가 힘들었다. +- 또한, 공통으로 사용되는 로직이 있는데, 이 부분도 fetch로 하려고 하니, 공통되는 부분을 추출하기가 힘들었다. +- axios 라이브러리 중 인터셉터를 활용하여 본래 가지고 있던 accessToken이 만료됨에 따라 재요청하는 로직을 공통으로 묶었다. +- axios로 바꿨더니, 기존 fetch 코드보다 조금 더 가독성이 향상 되었고, accessToken 재요청 로직을 재사용할 수 있었다. + +### 로그인 상태 감지 +- 요구사항에서 accessToken이 로컬스토리지에 존재하면 로그인 버튼이 프로필 아이콘으로 바뀌고, 존재하지 않으면 로그인 버튼이 렌더링되도록 구현해야 했다. +- 사용자가 로그아웃이나 웹사이트 최초 접속 후 로그인을 할 시 단순 이벤트로 처리하면 로컬스토리지의 값의 변화를 인식하지 못한다. +- 때문에 이 accessToken(로그인) 상태를 관리하는 것이 필요하다고 생각했고, 전역 상태관리를 해야한다고 생각했다. +- Context API나 Redux 등이 있지만, 간편하게 사용하기 위해서 Zustand 상태관리 라이브러리를 사용하였다. +- `useAuthStore`에 accessToken을 관리하도록 코드를 작성했고, 전역 상태이다 보니 다른 페이지나 컴포넌트의 상호작용으로 accessToken이 변경될 때 상태를 감지하도록 반영했다. + + ## 10주차 스프린트 ### 댓글 추가에 따른 revalidate 관련 이슈 diff --git a/package-lock.json b/package-lock.json index a2aafb934..564feb66f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource/pretendard": "^5.1.0", "@tanstack/react-query": "^5.64.1", + "axios": "^1.7.9", "date-fns": "^4.1.0", "dayjs": "^1.11.13", "es-toolkit": "^1.31.0", @@ -17,7 +18,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", - "react-intersection-observer": "^9.15.0" + "react-intersection-observer": "^9.15.0", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -912,7 +914,7 @@ "version": "19.0.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz", "integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.0.2" } @@ -1409,6 +1411,11 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1433,6 +1440,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1675,6 +1692,17 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1720,7 +1748,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -1850,6 +1878,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -2648,6 +2684,25 @@ "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -2673,6 +2728,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3667,6 +3735,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4293,6 +4380,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5591,6 +5683,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 0e88338df..42915fe76 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@fontsource/pretendard": "^5.1.0", "@tanstack/react-query": "^5.64.1", + "axios": "^1.7.9", "date-fns": "^4.1.0", "dayjs": "^1.11.13", "es-toolkit": "^1.31.0", @@ -18,7 +19,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", - "react-intersection-observer": "^9.15.0" + "react-intersection-observer": "^9.15.0", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/public/assets/icons/ic_facebook.svg b/public/assets/icons/ic_facebook.svg new file mode 100644 index 000000000..6305a4455 --- /dev/null +++ b/public/assets/icons/ic_facebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icons/ic_google.svg b/public/assets/icons/ic_google.svg new file mode 100644 index 000000000..a06cb5df2 --- /dev/null +++ b/public/assets/icons/ic_google.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/assets/icons/ic_instagram.svg b/public/assets/icons/ic_instagram.svg new file mode 100644 index 000000000..753664936 --- /dev/null +++ b/public/assets/icons/ic_instagram.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icons/ic_kakao.svg b/public/assets/icons/ic_kakao.svg new file mode 100644 index 000000000..9fefed24c --- /dev/null +++ b/public/assets/icons/ic_kakao.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/assets/icons/ic_twitter.svg b/public/assets/icons/ic_twitter.svg new file mode 100644 index 000000000..16047374a --- /dev/null +++ b/public/assets/icons/ic_twitter.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/assets/icons/ic_youtube.svg b/public/assets/icons/ic_youtube.svg new file mode 100644 index 000000000..51c36505d --- /dev/null +++ b/public/assets/icons/ic_youtube.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/assets/images/home-section1.png b/public/assets/images/home-section1.png new file mode 100644 index 000000000..0dfca5e05 Binary files /dev/null and b/public/assets/images/home-section1.png differ diff --git a/public/assets/images/home-section2.png b/public/assets/images/home-section2.png new file mode 100644 index 000000000..847b1eee3 Binary files /dev/null and b/public/assets/images/home-section2.png differ diff --git a/public/assets/images/home-section3.png b/public/assets/images/home-section3.png new file mode 100644 index 000000000..6b1ad447b Binary files /dev/null and b/public/assets/images/home-section3.png differ diff --git a/public/assets/images/home_bottom_banner.png b/public/assets/images/home_bottom_banner.png new file mode 100644 index 000000000..a6debf4da Binary files /dev/null and b/public/assets/images/home_bottom_banner.png differ diff --git a/src/actions/submit-article.ts b/src/actions/submit-article.ts index 2c845f79a..58a0fd1f6 100644 --- a/src/actions/submit-article.ts +++ b/src/actions/submit-article.ts @@ -1,59 +1,40 @@ 'use server'; +import { Article, ResponseWithAccessToken } from '@/types'; +import apiHelper from '@/utils/apiHelper'; +import { AxiosError } from 'axios'; -export async function submitArticle(formData: FormData, accessToken: string | null, refreshToken: string | null) { +interface submitArticleResponse { + success: boolean; + message: string; + accessToken?: string; + id?: number; +} + +export async function submitArticle(formData: FormData, accessToken: string | null, refreshToken: string | null): Promise { if (!accessToken || !refreshToken) { return { success: false, message: '로그인이 필요합니다.' }; } const formDataObject = Object.fromEntries(formData.entries()); try { - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, { - method: 'POST', + const response = await apiHelper.post
>('/articles', formDataObject, { headers: { - 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, + 'x-refresh-token': refreshToken, }, - body: JSON.stringify(formDataObject), }); - if (response.ok) { - const { id }: { id: number } = await response.json(); - return { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', id }; + const result: submitArticleResponse = { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', id: response.data.id }; + if ('accessToken' in response.data) { + const newAccessToken = response.data.accessToken as string; + result.accessToken = newAccessToken; } - if (response.status === 401) { - const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ refreshToken }), - }); - if (refreshResponse.ok) { - const { accessToken: newAccessToken } = await refreshResponse.json(); - - const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${newAccessToken}`, - }, - body: JSON.stringify(formDataObject), - }); - - if (retryResponse.ok) { - const { id }: { id: number } = await retryResponse.json(); - return { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', accessToken: newAccessToken, id }; - } else { - return { success: false, message: '게시글 생성 중 오류가 발생했습니다.' }; - } - } - + return result; + } catch (error) { + console.log(error); + if (error instanceof AxiosError && error.message.includes('리프레시')) { return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' }; } - return { success: false, message: '게시글 생성 중 오류가 발생했습니다.' }; - } catch (error) { - console.log(error); - return { success: false, message: '서버 요청에 실패했습니다.' }; } } diff --git a/src/actions/submit-comment.ts b/src/actions/submit-comment.ts index bdf6eeb9d..c180eda8c 100644 --- a/src/actions/submit-comment.ts +++ b/src/actions/submit-comment.ts @@ -1,57 +1,40 @@ 'use server'; +import apiHelper from '@/utils/apiHelper'; +import { AxiosError } from 'axios'; +import { Comment, ResponseWithAccessToken } from '@/types'; -export async function submitComment(formData: FormData, accessToken: string | null, refreshToken: string | null, id: number) { +interface SubmitCommentResponse { + success: boolean; + message: string; + accessToken?: string; +} + +export async function submitComment(formData: FormData, accessToken: string | null, refreshToken: string | null, id: number): Promise { if (!accessToken || !refreshToken) { return { success: false, message: '로그인이 필요합니다.' }; } const formDataObject = Object.fromEntries(formData.entries()); try { - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments`, { - method: 'POST', + const response = await apiHelper.post>(`/articles/${id}/comments`, formDataObject, { headers: { - 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, + 'x-refresh-token': refreshToken, }, - body: JSON.stringify(formDataObject), }); - - if (response.ok) { - return { success: true, message: '댓글 등록이 완료되었습니다.' }; + const result: SubmitCommentResponse = { success: true, message: '댓글 등록이 완료되었습니다.' }; + if ('accessToken' in response.data) { + const newAccessToken = response.data.accessToken as string; + result.accessToken = newAccessToken; } - if (response.status === 401) { - const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ refreshToken }), - }); - if (refreshResponse.ok) { - const { accessToken: newAccessToken } = await refreshResponse.json(); - - const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${newAccessToken}`, - }, - body: JSON.stringify(formDataObject), - }); - - if (retryResponse.ok) { - return { success: true, message: '댓글 등록이 완료되었습니다.', accessToken: newAccessToken }; - } else { - return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' }; - } - } + return result; + } catch (error) { + console.log(error); + if (error instanceof AxiosError && error.message.includes('리프레시')) { return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' }; } return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' }; - } catch (error) { - console.log(error); - return { success: false, message: '서버 요청에 실패했습니다.' }; } } diff --git a/src/actions/submit-login.ts b/src/actions/submit-login.ts index f918bbe3f..3155c8ccc 100644 --- a/src/actions/submit-login.ts +++ b/src/actions/submit-login.ts @@ -22,6 +22,10 @@ export async function submitLogin(_: any, formData: FormData) { }, body: JSON.stringify({ email, password }), }); + if (response.status === 400) { + const errorDetails = await response.json(); + throw new Error(errorDetails?.message || '잘못된 요청입니다.'); + } if (!response.ok) throw new Error(response.statusText); const responseJson: SigninSuccessResponse | SigninFailResponse = await response.json(); return { @@ -33,7 +37,7 @@ export async function submitLogin(_: any, formData: FormData) { console.error(err); return { status: false, - error: `로그인을 실패했습니다. : ${err}`, + error: `로그인을 실패했습니다. ${err instanceof Error ? err.message : err}`, }; } } diff --git a/src/actions/submit-signup.ts b/src/actions/submit-signup.ts new file mode 100644 index 000000000..353b969a9 --- /dev/null +++ b/src/actions/submit-signup.ts @@ -0,0 +1,33 @@ +'use server'; + +import { SignupFailResponse, SignupFormData, SignupResponse } from '@/types'; + +export async function submitSignup(formData: SignupFormData) { + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/signUp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + if (response.status === 400) { + const errorDetails: SignupFailResponse = await response.json(); + console.log(errorDetails.message); + throw new Error(errorDetails?.message || '잘못된 요청입니다.'); + } + if (!response.ok) throw new Error(response.statusText); + const responseJson: SignupResponse = await response.json(); + return { + response: responseJson, + status: true, + error: '', + }; + } catch (err) { + console.error(err); + return { + status: false, + error: `회원가입을 실패했습니다. ${err instanceof Error ? err.message : err}`, + }; + } +} diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx index a5a63c461..b5b3a609a 100644 --- a/src/app/(auth)/signin/page.tsx +++ b/src/app/(auth)/signin/page.tsx @@ -1,10 +1,11 @@ import SigninForm from '@/components/Auth/SigninForm'; import Link from 'next/link'; +import SimpleLoginContainer from '@/components/SimpleLoginContainer'; function SignupGuide() { return ( -
- 판다마켓이 처음이신가요? +
+ 판다마켓이 처음이신가요? 회원가입 @@ -12,21 +13,9 @@ function SignupGuide() { ); } -function SimpleLoginContainer() { - return ( -
- 간편 로그인하기 - -
- ); -} - export default function Signin() { return ( -
+
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index c184b4253..91de5d16e 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,3 +1,24 @@ +import SignupForm from '@/components/Auth/SignupForm'; +import SimpleLoginContainer from '@/components/SimpleLoginContainer'; +import Link from 'next/link'; + +function SigninGuide() { + return ( +
+ 이미 회원이신가요? + + 로그인 + +
+ ); +} + export default function Signup() { - return
회원가입 페이지입니다.
; + return ( +
+ + + +
+ ); } diff --git a/src/app/(with-Header)/faq/page.tsx b/src/app/(with-Header)/faq/page.tsx new file mode 100644 index 000000000..2267baf9b --- /dev/null +++ b/src/app/(with-Header)/faq/page.tsx @@ -0,0 +1,3 @@ +export default function Faq() { + return
FAQ 페이지입니다.
; +} diff --git a/src/app/(with-Header)/layout.tsx b/src/app/(with-Header)/layout.tsx index 621b79ddd..2aa3a4a3b 100644 --- a/src/app/(with-Header)/layout.tsx +++ b/src/app/(with-Header)/layout.tsx @@ -1,34 +1,5 @@ -import Link from 'next/link'; -import Image from 'next/image'; import { ReactNode } from 'react'; - -function Icon() { - return ( -
- 판다 얼굴 - 로고 문구 -
- ); -} - -function Header() { - return ( -
- -
- ); -} +import Header from '@/components/Header'; export default function Layout({ children }: { children: ReactNode }) { return ( diff --git a/src/app/(with-Header)/page.tsx b/src/app/(with-Header)/page.tsx index ac08857d5..f72520e8c 100644 --- a/src/app/(with-Header)/page.tsx +++ b/src/app/(with-Header)/page.tsx @@ -1,3 +1,133 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +function Footer() { + return ( + + ); +} + +interface Content { + keyword: string; + subTitle1: string; + subTitle2: string; + explain1: string; + explain2: string; +} + +const CONTENT_SECTION: Content[] = [ + { + keyword: 'Hot item', + subTitle1: '인기 상품을 ', + subTitle2: '확인해 보세요', + explain1: '가장 HOT한 중고거래 물품을', + explain2: '판다 마켓에서 확인해 보세요', + }, + { + keyword: 'Search', + subTitle1: '구매를 원하는 ', + subTitle2: '상품을 검색하세요', + explain1: '구매하고 싶은 물품을 검색해서', + explain2: '쉽게 찾아보세요', + }, + { + keyword: 'Register', + subTitle1: '판매를 원하는 ', + subTitle2: '상품을 등록하세요', + explain1: '어떤 물건이든 판매하고 싶은 상품을', + explain2: '쉽게 등록하세요', + }, +]; + +function ContentList() { + function makeContent(index: number) { + return ( +
+ 내용 설명 이미지 +
+

{CONTENT_SECTION[index].keyword}

+

+ {CONTENT_SECTION[index].subTitle1} + {CONTENT_SECTION[index].subTitle2} +

+

+ {CONTENT_SECTION[index].explain1} + {CONTENT_SECTION[index].explain2} +

+
+
+ ); + } + return Array.from({ length: 3 }, (_, index) => makeContent(index)); +} + +function BottomBanner() { + return ( +
+
+

믿을 수 있는

+

판다마켓 중고 거래

+
+ + 상단 배너 이미지 +
+ ); +} + +function TopBanner() { + return ( +
+
+

일상의 모든 물건을 거래해 보세요

+ + 구경하러 가기 + +
+ + 상단 배너 이미지 +
+ ); +} + export default function Home() { - return
hi
; + return ( +
+ + + +
+
+ ); } diff --git a/src/app/(with-Header)/privacy/page.tsx b/src/app/(with-Header)/privacy/page.tsx new file mode 100644 index 000000000..2b752dd62 --- /dev/null +++ b/src/app/(with-Header)/privacy/page.tsx @@ -0,0 +1,3 @@ +export default function Privacy() { + return
Privacy 페이지입니다.
; +} diff --git a/src/components/ArticleForm/index.tsx b/src/components/ArticleForm/index.tsx index 65d6dc9f0..3406f0ffd 100644 --- a/src/components/ArticleForm/index.tsx +++ b/src/components/ArticleForm/index.tsx @@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form'; import { submitArticle } from '@/actions/submit-article'; import { useState } from 'react'; import Image from 'next/image'; - import { ArticleFormData } from '@/types'; import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/useAuthStore'; export default function ArticleForm() { const { @@ -26,6 +26,7 @@ export default function ArticleForm() { const [resultMessage, setResultMessage] = useState(null); const [previewImage, setPreviewImage] = useState(null); const router = useRouter(); + const { setAccessToken } = useAuthStore(); const formValues = watch(); @@ -46,7 +47,10 @@ export default function ArticleForm() { reset({ title: '', content: '', image: null }); setPreviewImage(null); - if (result.accessToken) localStorage.setItem('accessToken', result.accessToken); + if (result.accessToken) { + localStorage.setItem('accessToken', result.accessToken); + setAccessToken(result.accessToken); + } if (result.success) { setTimeout(() => { diff --git a/src/components/Auth/SigninForm/index.tsx b/src/components/Auth/SigninForm/index.tsx index d39fd1888..26ab2e2bd 100644 --- a/src/components/Auth/SigninForm/index.tsx +++ b/src/components/Auth/SigninForm/index.tsx @@ -1,35 +1,91 @@ 'use client'; import { submitLogin } from '@/actions/submit-login'; +import { useAuthStore } from '@/stores/useAuthStore'; import { useRouter } from 'next/navigation'; -import { useActionState, useEffect } from 'react'; +import { useActionState, useEffect, useState } from 'react'; -const SUBMIT_BTN_CLASSNAME = 'mt-4 rounded-full py-4 bg-gray-400 font-semibold text-xl text-white'; +const SUBMIT_BTN_CLASSNAME = 'mt-4 rounded-full py-4 font-semibold text-xl text-white text-center'; + +interface Valid { + email: string; + password: string; + isEmailValid: boolean; + isPasswordValid: boolean; + isValid: boolean; +} export default function SigninForm() { const [state, action, isPending] = useActionState(submitLogin, null); + const [valid, setValid] = useState({ email: '', password: '', isEmailValid: true, isPasswordValid: true, isValid: false }); const router = useRouter(); + const { setAccessToken } = useAuthStore(); useEffect(() => { if (state && !state.status) alert(state.error); if (state?.response && 'accessToken' in state?.response) { localStorage.setItem('accessToken', state.response.accessToken); localStorage.setItem('refreshToken', state.response.refreshToken); + setAccessToken(state.response.accessToken); router.replace('/'); } - }, [state, router]); + }, [state, router, setAccessToken]); + + const validateInputs = (email: string, password: string) => { + const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + const isPasswordValid = password.length >= 8; + const isValid = isEmailValid && isPasswordValid; + return { isEmailValid, isPasswordValid, isValid }; + }; + + const handleInputChange = (field: 'email' | 'password', value: string) => { + setValid((prev) => { + const newValidState = { ...prev, [field]: value }; + const { isEmailValid, isPasswordValid, isValid } = validateInputs(newValidState.email, newValidState.password); + if (field === 'email') return { ...newValidState, isEmailValid, isValid }; + return { ...newValidState, isPasswordValid, isValid }; + }); + }; return ( -
+ - + handleInputChange('email', e.target.value)} + /> + {!valid.isEmailValid && 잘못된 이메일입니다.} - - {isPending ?
로그인 중입니다.
: } + handleInputChange('password', e.target.value)} + /> + {!valid.isPasswordValid && 8자리 이상 비밀번호를 입력해주세요.} + {isPending ? ( +
로그인 중입니다.
+ ) : ( + + )}
); } diff --git a/src/components/Auth/SignupForm/index.tsx b/src/components/Auth/SignupForm/index.tsx new file mode 100644 index 000000000..18c3cc65f --- /dev/null +++ b/src/components/Auth/SignupForm/index.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { submitSignup } from '@/actions/submit-signup'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { SignupFormData } from '@/types'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; + +export default function SignupForm() { + const { + register, + handleSubmit, + formState: { isSubmitting, errors, isValid }, + watch, + reset, + trigger, + } = useForm({ + defaultValues: { + email: '', + nickname: '', + password: '', + passwordConfirmation: '', + }, + mode: 'onChange', + }); + const router = useRouter(); + const { setAccessToken } = useAuthStore(); + + const onSubmit = async (data: SignupFormData) => { + const result = await submitSignup(data); + if (result.response && 'accessToken' in result.response) { + localStorage.setItem('accessToken', result.response.accessToken); + localStorage.setItem('refreshToken', result.response.refreshToken); + setAccessToken(result.response.accessToken); + reset(); + router.replace('/'); + return; + } + alert(result.error); + }; + + const preventEnterSubmit = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + } + }; + + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/CommentForm/index.tsx b/src/components/CommentForm/index.tsx index 1bd6f40c5..a6d479570 100644 --- a/src/components/CommentForm/index.tsx +++ b/src/components/CommentForm/index.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form'; import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/useAuthStore'; interface CommentFormData { content: string; @@ -26,6 +27,8 @@ export default function CommentForm({ id }: { id: string }) { const isFormValid = formValues.content; const queryClient = useQueryClient(); + const { setAccessToken } = useAuthStore(); + const onSubmit = async (data: CommentFormData) => { try { const formData = new FormData(); @@ -36,7 +39,10 @@ export default function CommentForm({ id }: { id: string }) { setResultMessage(result.message); reset({ content: '' }); - if (result.accessToken) localStorage.setItem('accessToken', result.accessToken); + if (result.accessToken) { + localStorage.setItem('accessToken', result.accessToken); + setAccessToken(result.accessToken); + } if (result.success) { queryClient.invalidateQueries({ diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 000000000..70bde78b9 --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1,75 @@ +'use client'; + +import Link from 'next/link'; +import Image from 'next/image'; +import { usePathname } from 'next/navigation'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useEffect, useState } from 'react'; +import { Dispatch, SetStateAction } from 'react'; + +type Setter = Dispatch>; + +function DropDown({ setIsClickProfile }: { setIsClickProfile: Setter }) { + const { setAccessToken } = useAuthStore(); + const onClick = () => { + setIsClickProfile(false); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setAccessToken(''); + }; + return ( +
    +
  • 로그아웃
  • +
+ ); +} + +function Icon() { + return ( +
+ 판다 얼굴 + 로고 문구 +
+ ); +} + +export default function Header() { + const pathname = usePathname(); + const { accessToken } = useAuthStore(); + + const [isMounted, setIsMounted] = useState(false); + const [isClickProfile, setIsClickProfile] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + return ( +
+ +
+ ); +} diff --git a/src/components/SimpleLoginContainer/index.tsx b/src/components/SimpleLoginContainer/index.tsx new file mode 100644 index 000000000..34bae6459 --- /dev/null +++ b/src/components/SimpleLoginContainer/index.tsx @@ -0,0 +1,17 @@ +import Image from 'next/image'; + +export default function SimpleLoginContainer() { + return ( +
+ 간편 로그인하기 + +
+ ); +} diff --git a/src/stores/useAuthStore.tsx b/src/stores/useAuthStore.tsx new file mode 100644 index 000000000..d230ee22e --- /dev/null +++ b/src/stores/useAuthStore.tsx @@ -0,0 +1,22 @@ +import { create } from 'zustand'; + +const getLocalStorageItem = (key: string, defaultValue: string) => { + if (typeof window !== 'undefined') { + const value = localStorage.getItem(key); + return value ? value : defaultValue; + } + return defaultValue; +}; + +interface Store { + accessToken: string; + setAccessToken: (accessToken: string) => void; +} + +export const useAuthStore = create((set) => ({ + accessToken: getLocalStorageItem('accessToken', ''), + setAccessToken: (accessToken: string) => { + localStorage.setItem('accessToken', accessToken); + set((state) => ({ ...state, accessToken })); + }, +})); diff --git a/src/types.ts b/src/types.ts index e42d30b9e..95f7477e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,3 +68,42 @@ export interface Comments { list: Comment[]; nextCursor: null | string; } + +export interface SignupFormData { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; +} + +export interface SignupResponse { + accessToken: string; + refreshToken: string; + user: { + id: number; + email: string; + image: null | string; + nickname: string; + updatedAt: string; + createdAt: string; + }; +} +export interface SignupFailResponse { + message: string; + details: { + email?: { + message: string; + }; + nickanme?: { + message: string; + }; + password?: { + message: string; + }; + passwordConfirmation?: { + message: string; + }; + }; +} + +export type ResponseWithAccessToken = T & { accessToken: string }; diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts new file mode 100644 index 000000000..806b89e68 --- /dev/null +++ b/src/utils/apiHelper.ts @@ -0,0 +1,39 @@ +import { ResponseWithAccessToken } from '@/types'; +import axios, { AxiosError, AxiosResponse } from 'axios'; + +const apiHelper = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +apiHelper.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + if (error.response?.status === 401) { + const refreshToken: string = error?.config?.headers?.['x-refresh-token'] ?? ''; + if (refreshToken) { + try { + const refreshResponse = await axios.post<{ accessToken: string }>(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, { refreshToken }); + + const { accessToken: newAccessToken } = refreshResponse.data; + + if (error.config) { + error.config.headers['Authorization'] = `baerer ${newAccessToken}`; + const response: AxiosResponse> = await apiHelper.request(error.config); + response.data.accessToken = newAccessToken; + return response; + } + } catch (refreshError) { + console.error('리프레시 토큰 생성 실패 : ', refreshError); + return Promise.reject(refreshError); + } + } + } + + return Promise.reject(error); + }, +); + +export default apiHelper; diff --git a/tailwind.config.ts b/tailwind.config.ts index dfc725ddf..8cc2dce7b 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -17,6 +17,7 @@ export default { 50: '#F9FAFB', }, blue: { + 40: '#CFE5FF', 50: '#E6F2FF', 100: '#3692FF', 200: '#1967D6', @@ -28,6 +29,7 @@ export default { }, screens: { pc: '1200px', + 'sm-mobile': '400px', }, }, },