diff --git a/package.json b/package.json index 9e6f0b3..ccad0bc 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "web-vitals": "^2.1.4" }, "scripts": { - "start": "set HTTPS=false&&react-scripts start", + "start": "set HTTPS=true && react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", diff --git a/public/favicon.ico b/public/favicon.ico index c9361a0..01117db 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/src/App.css b/src/App.css index 74b5e05..92b52ec 100644 --- a/src/App.css +++ b/src/App.css @@ -7,15 +7,9 @@ pointer-events: none; } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - .App-header { background-color: #282c34; - min-height: 100vh; + min-height: 100dvh; display: flex; flex-direction: column; align-items: center; @@ -27,12 +21,3 @@ .App-link { color: #61dafb; } - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.tsx b/src/App.tsx index 66dd6e6..b6840e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,6 @@ import Navbar from "./components/navbar/navbar"; import Search from "./pages/search/search"; import Time from "./pages/main/time/time"; import SearchView from "./pages/search/searchView"; -import MentorProfile from "./pages/mentorProfile/mentorProfile"; import ApplyCogoTime from "./pages/applyCogo/applyCogoTime"; import ApplyCogoMemo from "./pages/applyCogo/applyCogoMemo"; import ApplyCogoComplete from "./pages/applyCogo/applyCogoComplete"; @@ -25,6 +24,10 @@ import SendCogoDetail from "./pages/cogo/sendCogo/sendCogo_detail"; import Introduce from "./pages/mypage/Introduce/introduce"; import MyProfile from "./pages/mypage/myprofile/myprofile"; import TimeSelect from "./pages/mypage/timeselect/timeselect"; +import Intro from "./pages/intro/intro"; +import MentorDetails from "./pages/mentorDetails/mentorDetails"; +import CompleteCogoDetail from "./pages/cogo/completeCogo/completeCogo_detail"; +import CompleteCogo from "./pages/cogo/completeCogo/completeCogo"; function App() { return ( @@ -40,7 +43,7 @@ function AppContent() { const location = useLocation(); // 로그인, 콜백, 회원가입 페이지에서는 Navbar를 숨김 - const hideNavbar = ["/login", "/callback", "/signup"].includes( + const hideNavbar = ["/login", "/callback", "/signup", "/intro"].includes( location.pathname ); @@ -49,6 +52,7 @@ function AppContent() { {!hideNavbar && } } /> + } /> } /> } /> } /> @@ -57,13 +61,15 @@ function AppContent() { } /> } /> } /> - } /> + } /> } /> } /> } /> } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/apis/authAxiosInstance.tsx b/src/apis/authAxiosInstance.tsx new file mode 100644 index 0000000..1296249 --- /dev/null +++ b/src/apis/authAxiosInstance.tsx @@ -0,0 +1,114 @@ +import axios, { AxiosError, AxiosRequestConfig } from "axios"; + +const getTokenFromLocalStorage = () => { + return localStorage.getItem("token"); +}; + +const setTokenToLocalStorage = (token: string) => { + localStorage.setItem("token", token); +}; + +const saveRoleToLocalStorage = (role: string) => { + localStorage.setItem("role", role); +}; + +const authAxiosInstance = axios.create({ + baseURL: "https://cogo.life", + headers: { + "Content-Type": "application/json", + }, +}); + +let isRefreshing = false; +let failedQueue: any[] = []; + +const processQueue = (error: AxiosError | null, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + + failedQueue = []; +}; + +authAxiosInstance.interceptors.request.use( + (config) => { + const token = getTokenFromLocalStorage(); + console.log("로컬스토리지에 토큰 저장: ", token); + if (token) { + config.headers = config.headers || {}; // headers가 undefined일 수 있으므로 초기화 + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +authAxiosInstance.interceptors.response.use( + (response) => { + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + try { + const token = await new Promise(function (resolve, reject) { + failedQueue.push({ resolve, reject }); + }); + originalRequest.headers = originalRequest.headers || {}; // headers가 undefined일 수 있으므로 초기화 + originalRequest.headers.Authorization = "Bearer " + token; + return await authAxiosInstance(originalRequest); + } catch (err) { + return await Promise.reject(err); + } + } + + originalRequest._retry = true; + isRefreshing = true; + + return new Promise(function (resolve, reject) { + axios + .post( + "https://cogo.life/reissue", + {}, + { + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + } + ) + .then(({ data }) => { + const newToken = data.accessToken; + setTokenToLocalStorage(newToken); + authAxiosInstance.defaults.headers.Authorization = "Bearer " + newToken; + + originalRequest.headers = originalRequest.headers || {}; // headers가 undefined일 수 있으므로 초기화 + originalRequest.headers.Authorization = "Bearer " + newToken; + + processQueue(null, newToken); + resolve(authAxiosInstance(originalRequest)); + }) + .catch((err) => { + processQueue(err, null); + // 토큰 재발급 실패 시 로그아웃 처리 등 추가 작업 + reject(err); + }) + .finally(() => { + isRefreshing = false; + }); + }); + } + + return Promise.reject(error); + } +); + +export default authAxiosInstance; diff --git a/src/apis/authAxiosInstanceReissue.tsx b/src/apis/authAxiosInstanceReissue.tsx new file mode 100644 index 0000000..16c0a03 --- /dev/null +++ b/src/apis/authAxiosInstanceReissue.tsx @@ -0,0 +1,11 @@ +import axios from "axios"; + +const authAxiosInstanceReissue = axios.create({ + baseURL: "https://cogo.life", + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, +}); + +export default authAxiosInstanceReissue; diff --git a/src/apis/axiosInstance.tsx b/src/apis/axiosInstance.tsx index 7a52d89..127a63a 100644 --- a/src/apis/axiosInstance.tsx +++ b/src/apis/axiosInstance.tsx @@ -1,24 +1,114 @@ -import axios from "axios"; +import axios, { AxiosError, AxiosRequestConfig } from "axios"; const getTokenFromLocalStorage = () => { return localStorage.getItem("token"); }; +const setTokenToLocalStorage = (token: string) => { + localStorage.setItem("token", token); +}; + +const saveRoleToLocalStorage = (role: string) => { + localStorage.setItem("role", role); +}; + const axiosInstance = axios.create({ - baseURL: 'https://cogo.life/api/v2', + baseURL: "https://cogo.life/api/v2", headers: { "Content-Type": "application/json", }, }); -axiosInstance.interceptors.request.use((config) => { - const token = getTokenFromLocalStorage(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; +let isRefreshing = false; +let failedQueue: any[] = []; + +const processQueue = (error: AxiosError | null, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + + failedQueue = []; +}; + +axiosInstance.interceptors.request.use( + (config) => { + const token = getTokenFromLocalStorage(); + console.log("로컬스토리지에 토큰 저장: ", token); + if (token) { + config.headers = config.headers || {}; // headers가 undefined일 수 있으므로 초기화 + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); } - return config; -}, (error) => { - return Promise.reject(error); -}); +); + +axiosInstance.interceptors.response.use( + (response) => { + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + try { + const token = await new Promise(function (resolve, reject) { + failedQueue.push({ resolve, reject }); + }); + originalRequest.headers = originalRequest.headers || {}; // headers가 undefined일 수 있으므로 초기화 + originalRequest.headers.Authorization = "Bearer " + token; + return await axiosInstance(originalRequest); + } catch (err) { + return await Promise.reject(err); + } + } + + originalRequest._retry = true; + isRefreshing = true; + + return new Promise(function (resolve, reject) { + axios + .post( + "https://cogo.life/reissue", + {}, + { + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + } + ) + .then(({ data }) => { + const newToken = data.accessToken; + setTokenToLocalStorage(newToken); + axiosInstance.defaults.headers.Authorization = "Bearer " + newToken; + + originalRequest.headers = originalRequest.headers || {}; // headers가 undefined일 수 있으므로 초기화 + originalRequest.headers.Authorization = "Bearer " + newToken; + + processQueue(null, newToken); + resolve(axiosInstance(originalRequest)); + }) + .catch((err) => { + processQueue(err, null); + // 토큰 재발급 실패 시 로그아웃 처리 등 추가 작업 + reject(err); + }) + .finally(() => { + isRefreshing = false; + }); + }); + } + + return Promise.reject(error); + } +); -export default axiosInstance; \ No newline at end of file +export default axiosInstance; diff --git a/src/apis/axiosReissue.tsx b/src/apis/axiosReissue.tsx deleted file mode 100644 index 8fc5bfb..0000000 --- a/src/apis/axiosReissue.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import axios, { AxiosRequestConfig, AxiosError } from 'axios'; -import axiosInstance from './axiosInstance'; - -export interface ExtendedAxiosRequestConfig extends AxiosRequestConfig { - _retry?: boolean; -} - -const reissueToken = async () => { - try { - const response = await axios.post( - 'https://cogo.life/reissue', - {}, - { - headers: { - 'Content-Type': 'application/json', - }, - withCredentials: true, - } - ); - const newToken = response.data.accessToken; - localStorage.setItem('accessToken', newToken); - return newToken; - } catch (error) { - console.error('Failed to reissue access token:', error); - throw error; - } -}; - -const requestWithReissue = async (config: ExtendedAxiosRequestConfig) => { - try { - return await axiosInstance(config); - } catch (error) { - const axiosError = error as AxiosError; - const originalRequest = axiosError.config as ExtendedAxiosRequestConfig; - - if (axiosError.response?.status === 401 && originalRequest && !originalRequest._retry) { - originalRequest._retry = true; - - try { - const newToken = await reissueToken(); - if (originalRequest.headers) { - originalRequest.headers['Authorization'] = `Bearer ${newToken}`; - } else { - originalRequest.headers = { Authorization: `Bearer ${newToken}` }; - } - return axiosInstance(originalRequest); - } catch (reissueError) { - console.error('Failed to reissue access token:', reissueError); - throw reissueError; - } - } else { - console.log('An unexpected error occurred'); - throw error; - } - } -}; - -export default requestWithReissue; \ No newline at end of file diff --git a/src/assets/Camera.svg b/src/assets/Camera.svg new file mode 100644 index 0000000..5a15287 --- /dev/null +++ b/src/assets/Camera.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/GoogleButton.svg b/src/assets/GoogleButton.svg index 0f99b68..4f1d915 100644 --- a/src/assets/GoogleButton.svg +++ b/src/assets/GoogleButton.svg @@ -1,9 +1,15 @@ - - + + + + + + + + + - - - - + + + diff --git a/src/assets/Intro-1.json b/src/assets/Intro-1.json new file mode 100644 index 0000000..1397f53 --- /dev/null +++ b/src/assets/Intro-1.json @@ -0,0 +1 @@ +{"nm":"Main Scene","ddd":0,"h":500,"w":500,"meta":{"g":"@lottiefiles/creator 1.25.0"},"layers":[{"ty":4,"nm":"1","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.36,"y":1},"s":[184.6353,250.0251],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[184.6353,250.0251],"t":15},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[184.6353,223.6865],"t":30},{"s":[184.6353,250.0176],"t":42}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[9.5244,0],[0,-9.922210000000002],[-10.00298,0],[-0.7519999999999953,9.2441],[0,0],[0,0],[0,0]],"o":[[-0.7519999999999953,-9.244179999999998],[-10.00298,0],[0,9.9223],[10.0029,0],[0,0],[0,0],[0,0],[0,0]],"v":[[36.18379999999999,16.52199999999999],[18.126100000000008,0],[0,17.97980000000001],[18.126100000000008,35.9597],[36.18379999999999,19.437700000000007],[18.752700000000004,19.437700000000007],[18.752700000000004,16.510699999999986],[36.18379999999999,16.510699999999986]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[17.8647,17.97489999999999]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[17.8647,17.97489999999999]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"2","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.55,"y":0.06},"i":{"x":0.36,"y":1},"s":[228.6353,250.0251],"t":42},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[228.6353,223.6865],"t":57},{"s":[228.6353,250.0176],"t":69}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,9.93],[10.026,0],[0,-9.929960000000001],[-10.026139999999998,0]],"o":[[10.026,0],[0,-9.929960000000001],[-10.026139999999998,0],[0,9.93],[0,0]],"v":[[18.1805,35.9597],[36.3343,17.9798],[18.1805,0],[0.0266113,17.9798],[18.1805,35.9597]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[17.8647,17.982400000000013]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[17.8647,17.982400000000013]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2},{"ty":4,"nm":"3","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.55,"y":0.06},"i":{"x":0.36,"y":1},"s":[273.1561,250.0226],"t":69},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[273.1561,223],"t":83},{"s":[273.1561,250],"t":94}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[6.675000000000001,0],[0,-9.922210000000002],[-9.984110000000001,0],[0,9.9223],[0,0],[0,0],[0,0]],"o":[[-3.1385000000000005,-5.35665],[-9.99549,0],[0,9.9223],[9.984099999999998,0],[0,0],[0,0],[0,0],[0,0]],"v":[[33.8701,8.96166],[18.2345,0],[0.142578,17.9798],[18.2345,35.9597],[36.3264,17.9798],[18.2458,17.9798],[33.8701,8.95036],[33.8701,8.96166]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":3},{"ty":4,"nm":"4","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.55,"y":0.06},"i":{"x":0.36,"y":1},"s":[316.0116,250.0226],"t":94},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[316.0116,223],"t":107},{"s":[316.0116,250],"t":120}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,9.93],[9.991900000000001,0],[0,-9.929960000000001],[-9.99189,0]],"o":[[9.991900000000001,0],[0,-9.929960000000001],[-9.99189,0],[0,9.93],[0,0]],"v":[[18.2611,35.9597],[36.353,17.9798],[18.2611,0],[0.169189,17.9798],[18.2611,35.9597]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[18.488400000000013,17.97739999999999]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[18.488400000000013,17.97739999999999]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":4}],"v":"5.7.0","fr":30,"op":150,"ip":0,"assets":[]} \ No newline at end of file diff --git a/src/assets/Intro.json b/src/assets/Intro.json new file mode 100644 index 0000000..f6ccacd --- /dev/null +++ b/src/assets/Intro.json @@ -0,0 +1 @@ +{"nm":"Main Scene","ddd":0,"h":500,"w":500,"meta":{"g":"@lottiefiles/creator 1.25.0"},"layers":[{"ty":4,"nm":"1","sr":1,"st":0,"op":75,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":3},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":6},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":25},{"s":[277.7628,277.7628],"t":28}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.36,"y":1},"s":[68.4413,250],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[68.4413,270],"t":3},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[68.4413,165],"t":15},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[68.4413,270],"t":25},{"s":[68.4413,250],"t":32}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[9.5244,0],[0,-9.922210000000002],[-10.00298,0],[-0.7519999999999953,9.2441],[0,0],[0,0],[0,0]],"o":[[-0.7519999999999953,-9.244179999999998],[-10.00298,0],[0,9.9223],[10.0029,0],[0,0],[0,0],[0,0],[0,0]],"v":[[36.18379999999999,16.52199999999999],[18.126100000000008,0],[0,17.97980000000001],[18.126100000000008,35.9597],[36.18379999999999,19.437700000000007],[18.752700000000004,19.437700000000007],[18.752700000000004,16.510699999999986],[36.18379999999999,16.510699999999986]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[17.98110402112882,17.97724677314602]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[17.98110402112882,17.97724677314602]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"2","sr":1,"st":0,"op":75,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":11},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":14},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":17},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":36},{"s":[277.7628,277.7628],"t":39}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[190.6569,250],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[190.6569,250],"t":11},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[190.6569,270],"t":14},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[190.6569,165],"t":26},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[190.6569,270],"t":36},{"s":[190.6569,250],"t":43}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,9.93],[10.026,0],[0,-9.929960000000001],[-10.026139999999998,0]],"o":[[10.026,0],[0,-9.929960000000001],[-10.026139999999998,0],[0,9.93],[0,0]],"v":[[18.1805,35.9597],[36.3343,17.9798],[18.1805,0],[0.0266113,17.9798],[18.1805,35.9597]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[18.26350324809512,17.894742056904036]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[18.26350324809512,17.894742056904036]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2},{"ty":4,"nm":"3","sr":1,"st":0,"op":75,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":22},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":25},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":28},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":47},{"s":[277.7628,277.7628],"t":50}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[314.3191,250],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[314.3191,250],"t":22},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[314.3191,270],"t":25},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[314.3191,165],"t":37},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[314.3191,270],"t":47},{"s":[314.3191,250],"t":54}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[6.675000000000001,0],[0,-9.922210000000002],[-9.984110000000001,0],[0,9.9223],[0,0],[0,0],[0,0]],"o":[[-3.1385000000000005,-5.35665],[-9.99549,0],[0,9.9223],[9.984099999999998,0],[0,0],[0,0],[0,0],[0,0]],"v":[[33.8701,8.96166],[18.2345,0],[0.142578,17.9798],[18.2345,35.9597],[36.3264,17.9798],[18.2458,17.9798],[33.8701,8.95036],[33.8701,8.96166]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[18.371941095063818,18.000000000000014]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[18.371941095063818,18.000000000000014]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":3},{"ty":4,"nm":"4","sr":1,"st":0,"op":75,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[18.5,18]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":33},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":36},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,277.7628],"t":39},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[277.7628,260],"t":58},{"s":[277.7628,277.7628],"t":61}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[433.3557,250],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[433.3557,250],"t":33},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[433.3557,270],"t":36},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[433.3557,165],"t":48},{"o":{"x":0.65,"y":0},"i":{"x":0.36,"y":1},"s":[433.3557,270],"t":58},{"s":[433.3557,250],"t":65}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,9.93],[9.991900000000001,0],[0,-9.929960000000001],[-9.99189,0]],"o":[[9.991900000000001,0],[0,-9.929960000000001],[-9.99189,0],[0,9.93],[0,0]],"v":[[18.2611,35.9597],[36.353,17.9798],[18.2611,0],[0.169189,17.9798],[18.2611,35.9597]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0,0,0]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[18.488400000000013,17.97739999999999]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[18.488400000000013,17.97739999999999]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":4}],"v":"5.7.0","fr":30,"op":75,"ip":0,"assets":[]} \ No newline at end of file diff --git a/src/assets/Logo.svg b/src/assets/Logo.svg index 0341569..51cca41 100644 --- a/src/assets/Logo.svg +++ b/src/assets/Logo.svg @@ -1,26 +1,6 @@ - - - - - - - - - - - - - - \ No newline at end of file + + + + + + diff --git a/src/assets/ProfileBase.svg b/src/assets/ProfileBase.svg new file mode 100644 index 0000000..cae7fe0 --- /dev/null +++ b/src/assets/ProfileBase.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/global.styles.tsx b/src/components/global.styles.tsx index 9cc75cb..c8b28c6 100644 --- a/src/components/global.styles.tsx +++ b/src/components/global.styles.tsx @@ -153,8 +153,8 @@ export const HalfFixedButton = styled.button` font-weight: 500; width: calc(50% - 5rem); max-width: 234px; - bottom: 10.5rem; - // box-shadow: 0 0.4rem 1.3rem rgba(0, 0, 0, 0.25); + bottom: 11.5rem; + // box-shadow: 0 0.3rem 1.3rem rgba(0, 0, 0, 0.25); z-index: 100; `; diff --git a/src/components/loading/loading.styles.tsx b/src/components/loading/loading.styles.tsx new file mode 100644 index 0000000..c2c62b5 --- /dev/null +++ b/src/components/loading/loading.styles.tsx @@ -0,0 +1,15 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 35%; +`; + +export const Text = styled.p` + font-size: 2rem; + text-align: center; + margin-top: -1rem; +`; diff --git a/src/components/loading/loading.tsx b/src/components/loading/loading.tsx new file mode 100644 index 0000000..01546b5 --- /dev/null +++ b/src/components/loading/loading.tsx @@ -0,0 +1,17 @@ +import IntroAnim from "../../assets/Intro.json"; +import Lottie from "lottie-react"; +import * as S from './loading.styles' + +export default function Loading() { + return ( + + + 로딩 중이에요... + + ); +} diff --git a/src/components/mentorCard/mentorCard.style.tsx b/src/components/mentorCard/mentorCard.style.tsx new file mode 100644 index 0000000..5d4ec93 --- /dev/null +++ b/src/components/mentorCard/mentorCard.style.tsx @@ -0,0 +1,100 @@ +import styled from "styled-components"; + +export const ProfileCard = styled.div` + width: 100%; + min-height: 31.25rem; + border-radius: 2.2rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: white; + box-shadow: 0 0.2rem 1.3rem rgba(163, 163, 163, 0.25); + position: relative; +`; + +export const ProfileBox = styled.div` + width: 100%; + height: 19rem; + display: flex; + align-items: center; + justify-content: center; + background-color: #e4e4e4; + margin: 0 6rem; + border-radius: 2.2rem 2.2rem 0 0; + overflow: hidden; +`; + +export const ProfileImg = styled.img` + width: 100%; + height: auto; +`; + +export const ProfileBase = styled.img` + height: 52%; + width: auto; +`; + +export const ProfileTagContainer = styled.div` + position: absolute; + z-index: 5; + top: 0; + left: 0; + margin: 1.85rem 0 0 1.85rem; + display: flex; + gap: 0.75rem; +`; + +export const ProfileTag = styled.div` + font-weight: 500; + font-size: 1.5rem; + color: white; + background-color: black; + border-radius: 3rem; + padding: 0.75rem 2.1rem; +`; + +export const ProfileContentsBox = styled.div` + width: 100%; + height: 40%; + display: flex; + flex-direction: column; + background-color: white; + margin: 0 6rem; + border-radius: 0 0 2.2rem 2.2rem; + padding: 1.8rem 2.2rem; +`; + +export const ProfileName = styled.span` + font-size: 2.4rem; + font-weight: 600; + margin-bottom: 1.3rem; +`; + +export const ProfileBottomContainer = styled.div` + width: 100%; + bottom: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +export const ProfileTitle = styled.span` + width: 100%; + font-weight: 500; + font-size: 1.75rem; + color: #4b4b4b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const ProfileContents = styled.span` + width: 100%; + font-weight: 500; + font-size: 1.5rem; + color: #aeaeb2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/src/components/mentorCard/mentorCard.tsx b/src/components/mentorCard/mentorCard.tsx new file mode 100644 index 0000000..5d286e2 --- /dev/null +++ b/src/components/mentorCard/mentorCard.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import * as S from './mentorCard.style'; +import ProfileBase from '../../assets/ProfileBase.svg'; + +interface MentorData { + mentorId: number; + picture: string; + mentorName: string; + club: string; + part: string; + username: string; + title: string; + description: string; +} + +interface MentorCardProps { + mentor: MentorData; + onClick: (mentor: MentorData) => void; +} + +const MentorCard: React.FC = ({ mentor, onClick }) => { + return ( + onClick(mentor)}> + + {mentor.picture ? ( + <> + + + ) : ( + + )} + + {mentor.part} + {mentor.club} + + + + {mentor.mentorName} 멘토님 + + {mentor.title} + {mentor.description} + + + + ); +}; + +export default MentorCard; diff --git a/src/components/mentorProfileRequestModal/mentorProfileRequestModal.styles.tsx b/src/components/mentorProfileRequestModal/mentorProfileRequestModal.styles.tsx new file mode 100644 index 0000000..283239d --- /dev/null +++ b/src/components/mentorProfileRequestModal/mentorProfileRequestModal.styles.tsx @@ -0,0 +1,310 @@ +import styled from "styled-components"; + +export const SearchContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + border-radius: 5rem; + border: 1px solid black; + margin: 2rem 0 0 0 ; + padding: 0.3rem 2rem 0.3rem 0.75rem; +`; + +export const SearchIcon = styled.img` + height: 4.5rem; + width: auto; + cursor: pointer; +`; + +export const SearchInput = styled.input` + font-size: 2.2rem; + max-width: 24rem; + border-radius: 0; + &::placeholder { + color: #aeaeb2; + } +`; + +export const TagContainer = styled.div` + display: flex; + align-items: center; + gap: 1rem; +`; + +export const Tag = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + background-color: black; + color: white; + border-radius: 3rem; + padding: 1rem 2rem; +`; + +export const TagText = styled.span` + font-size: 1.6rem; + font-weight: 500; +`; + +export const TagDeleteBtn = styled.img` + width: 1.5rem; + height: 1.5rem; +`; + +export const ButtonTitle = styled.p` + font-size: 1.6rem; + color: #aeaeb2; +`; + +export const ButtonContainer = styled.div` + display: grid; + width: 100%; + grid-template-columns: 1fr 1fr; + column-gap: 1.5rem; + margin-bottom: 3rem; + + & > :nth-last-child(1):nth-child(odd) { + grid-column: span 2; // 마지막 요소가 가득 차도록 설정 + } +`; + +export const OptionButton = styled.button<{ isSelected?: boolean }>` + padding: 1.6rem; + font-size: 2.2rem; + font-weight: 500; + width: 100%; + margin: 1.5rem auto 0 auto; + flex: 1; + border: 1px solid #C1C1C1; + background-color: ${(props) => (props.isSelected ? "black" : "#F8F8F8")}; + color: ${(props) => (props.isSelected ? "white" : "#C1C1C1")}; +`; + +export const HeaderButtonContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +`; + +interface HeaderButtonProps { + $active?: boolean; +} + +export const HeaderButton = styled.button` + padding: 1rem 2rem; + font-size: 1.6rem; + font-weight: 500; + margin: 2rem 0; + border: ${(props) => (props.$active ? "none" : "1px solid #e2e2e2")}; + color: ${(props) => (props.$active ? "white" : "#8F8F8F")}; + background-color: ${(props) => (props.$active ? "black" : "white")}; + width: 10rem; +`; + +export const Hr = styled.hr` + width: 100%; + border: 0.5px solid #c1c1c1; +`; + +export const HeaderTitleContainer = styled.div` + display: flex; + flex-direction: column; + margin: 2rem 0 -3rem 0; +`; + +export const BodyContainer = styled.div` + width: 100%; + height: 90%; + display: flex; + flex-direction: column; + gap: 1.75rem; + margin: -1.5rem 0 0 0; + padding: 1rem 0.75rem 1rem 0.75rem; + overflow-y: auto; + /* 웹킷 기반 브라우저 */ + ::-webkit-scrollbar { + width: 8px; + } + ::-webkit-scrollbar-track { + background-color: #f1f1f1; + } + ::-webkit-scrollbar-thumb { + background-color: #888; + border-radius: 5rem; + } + ::-webkit-scrollbar-thumb:hover { + background-color: #555; + } + /* 파이어폭스 */ + scrollbar-width: thin; + scrollbar-color: #888 #f1f1f1; +`; + +export const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 90%; +`; + +export const ProfileCardContainer = styled.div` + display: flex; + overflow-y: auto; + flex-direction: column; + width: 100%; + height: calc(100dvh - 26rem); + padding: 2rem; + gap: 2rem; +`; + +export const ProfileCard = styled.div` + border-radius: 2.2rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: white; + // box-shadow: 0.5rem 0.5rem 2rem rgba(162, 162, 162, 0.5); + box-shadow: -0.5rem -0.6rem 0.3rem 0 rgba(239, 239, 239, 0.3), + 0.6rem 0.6rem 1.2rem 0 rgba(163, 163, 163, 0.3); + position: relative; + padding: 3rem 2rem; + gap: 2rem; +`; + +export const ProfileClub = styled.div` + position: absolute; + color: #E0E0E0; + font-size: 1.8rem; + font-weight: 600; + letter-spacing: 0.4rem; + left: 2rem; + bottom: 2rem; +`; + +export const ProfileName = styled.span` + width: 100%; + font-size: 1.8rem; + font-weight: 600; +`; + +export const ProfileMiddleContainer = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +`; + +export const ProfileText = styled.span` + width: calc(100% - 14rem); + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const ProfileTitle = styled.span` + width: 100%; + font-weight: 600; + font-size: 1.5rem; +`; + +export const ProfileContents = styled.span` + width: 100%; + font-weight: 600; + font-size: 1.2rem; + color: #606060; +`; + +export const ProfileCircle = styled.div` + width: 11.8rem; + height: 11.8rem; + display: flex; + align-items: center; + justify-content: center; + background: var( + --Linear, + linear-gradient( + 236deg, + #eb4436 20.97%, + #f6b805 43.66%, + #4286f5 63.36%, + #149a5d 82.85% + ) + ); + border-radius: 50%; +`; + +export const ProfileImg = styled.img` + width: 11rem; + height: 11rem; + border-radius: 50%; +`; + +export const ProfileBottomContainer = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; + gap: 1rem; +`; + +export const ProfileIcon = styled.div` + font-weight: 500; + font-size: 1.5rem; + color: white; + background-color: black; + border-radius: 3rem; + padding: 0.65rem 2rem; +`; + +export const BodyIntroduce = styled.div` + flex: 5; + display: flex; + justify-content: space-around; + height: 50%; + flex-direction: column; + margin-left: 1rem; + margin-right: 1rem; + transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out; + transform: translateX(0%); // 초기 위치 +`; + +export const BodyIntroduceHeader = styled.div` + margin-top: 10px; + font-size: 20px; + font-weight: bold; +`; +export const BodyIntroduceText = styled.div` + margin: 10px 0; + max-height: 70%; + overflow-y: auto; + font-size: 12px; +`; + +export const ApplyButton = styled.div` + display: flex; + justify-content: center; + align-items: center; + background: linear-gradient(to left, #62c6c4 0%, #02a6cb 100%); + /* width: 360px; */ + height: 50px; + border-radius: 7px; + font-size: 16px; + font-weight: bold; + color: white; + cursor: pointer; +`; +export const CardWrapper = styled.div` + display: flex; + width: 300%; + overflow: hidden; +`; + +export const Card = styled.div` + min-width: 100%; + transition: all 0.5s ease; +`; diff --git a/src/components/mentorProfileRequestModal/mentorProfileRequestModal.tsx b/src/components/mentorProfileRequestModal/mentorProfileRequestModal.tsx new file mode 100644 index 0000000..ea85286 --- /dev/null +++ b/src/components/mentorProfileRequestModal/mentorProfileRequestModal.tsx @@ -0,0 +1,109 @@ +import { useEffect } from "react"; +import * as S from "./mentorProfileRequestModal.styles"; +import { useNavigate } from "react-router-dom"; +import { useRecoilState } from "recoil"; +import { + Container, + Header, +} from "../../components/global.styles"; +import SearchIcon from "../../assets/Search.svg"; +import BackButton from "../../components/button/backButton"; +import TagDelete from "../../assets/TagDelete.svg"; +import { partSearchState, clubSearchState } from "../../atoms/authState"; + +export default function Search() { + const navigate = useNavigate(); + const [part, setPart] = useRecoilState(partSearchState); + const [club, setClub] = useRecoilState(clubSearchState); + + useEffect(() => { + setPart(""); + setClub(""); + }, []); + + const handleSearchView = () => { + if (club === "" && part === "") { + alert("검색할 파트나 동아리를 선택해주세요."); + } else { + const params = new URLSearchParams(); + if (part !== "") { + params.append("part", part); + } + if (club !== "") { + params.append("club", club); + } + navigate(`/search/searchview?${params.toString()}`); + } + }; + + const deletePartTag = () => { + setPart(""); + }; + + const deleteClubTag = () => { + setClub(""); + }; + + const togglePart = (option: string) => { + setPart(part === option ? "" : option); + }; + + const toggleClub = (option: string) => { + setClub(club === option ? "" : option); + }; + + return ( + +
+ + + + {part !== "" && ( + + {part} + + + )} + {club !== "" && ( + + {club} + + + )} + + + +
+ + 파트 + + {["FE", "BE", "PM", "DESIGN"].map((option) => ( + togglePart(option)} + > + {option} + + ))} + + 동아리 + + {["GDSC", "YOURSSU", "UMC", "LIKELION"].map((option) => ( + toggleClub(option)} + > + {option} + + ))} + + +
+ ); +} diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index 9cbdd68..559b88e 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -21,8 +21,8 @@ const Navbar = () => { 코고 - - + + MY diff --git a/src/index.css b/src/index.css index 4e34d0a..c6e6b9f 100644 --- a/src/index.css +++ b/src/index.css @@ -33,7 +33,7 @@ body { max-width: 520px; width: 100%; - min-height: 100dvh; + height: 100dvh; background-color: white; box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; display: flex; diff --git a/src/pages/applyCogo/applyCogo.styles.tsx b/src/pages/applyCogo/applyCogo.styles.tsx index 2c0935d..b579858 100644 --- a/src/pages/applyCogo/applyCogo.styles.tsx +++ b/src/pages/applyCogo/applyCogo.styles.tsx @@ -1,118 +1,59 @@ +// applyCogo.styles.tsx + import styled from "styled-components"; import Calendar from "react-calendar"; import "react-calendar/dist/Calendar.css"; import { Link } from "react-router-dom"; // 캘린더를 감싸주는 스타일 -export const StyledCalendarWrapper = styled.div` +export const CalendarWrapper = styled.div` width: 100%; display: flex; - justify-content: center; - position: relative; - .react-calendar { - width: 100%; - border: none; - background-color: white; - } - - // 비활성화된 날짜 스타일 - .react-calendar__tile:disabled{ - background-color: white; - color: #AEAEB2; - abbr { - color: #AEAEB2; - } - } - - /* 전체 폰트 컬러 */ - .react-calendar__month-view { - abbr { - font-size: 1.8rem; - font-weight: 400; - color: black; - } - } + overflow-x: auto; + padding: 1rem 0 2rem 0; + min-width: 0; + border-bottom: 1px solid #EDEDED; +`; - /* 네비게이션 가운데 정렬 */ - .react-calendar__navigation { - justify-content: center; - } +// 년도와 월을 표시하는 서클 +export const MonthYearCircle = styled.div` + width: 6rem; + height: 6rem; + border-radius: 50%; + background-color: transparent; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + margin-right: 1.9rem; + color: #626262; + border: 1px solid #AEAEB2; +`; - /* 네비게이션 폰트 설정 */ - .react-calendar__navigation button { - font-weight: 600; - font-size: 2rem; - color: black; - } +// 날짜를 표시하는 서클 +export const Circle = styled.div<{ isSelected?: boolean }>` + width: 6rem; + height: 6rem; + border-radius: 50%; + background-color: ${({ isSelected }) => (isSelected ? "#000" : "#EDEDED")}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + margin-right: 1.9rem; + color: ${({ isSelected }) => (isSelected ? "#fff" : "#626262")}; + cursor: pointer; +`; - /* 네비게이션 버튼 컬러 */ - .react-calendar__navigation button:focus { - background-color: white; - } +export const DateText = styled.p` + font-size: 2rem; + font-weight: 500; +`; - /* 네비게이션 비활성화 됐을때 스타일 */ - .react-calendar__navigation button:disabled { - background-color: white; - color: ${(props) => props.theme.darkBlack}; - } - /* 년/월 상단 네비게이션 칸 크기 줄이기 */ - .react-calendar__navigation__label { - flex-grow: 0 !important; - } - /* 요일 밑줄 제거 */ - .react-calendar__month-view__weekdays { - margin-bottom: 2rem; - abbr { - text-decoration: none; - font-size: 1.8rem; - font-weight: 500; - } - } - /* 일 날짜 간격 */ - .react-calendar__tile { - padding: 2rem 0; - position: relative; - } - /* 선택한 날짜 스타일 적용 */ - .react-calendar__tile:enabled:hover, - .react-calendar__tile:enabled:focus, - .react-calendar__tile--active { - border-radius: 20rem; - color: white; - background-color: transparent; - padding: 0; - /* abbr { - display: flex; - width: 3.6rem; - height: 3.6rem; - align-items: center; - justify-content: center; - margin: auto; - background-color: black; - border-radius: 5rem; - color: white; - font-weight: 600; - } */ - } - .react-calendar__tile--now { - background-color: white; - } - .selected-date { - background-color: white; - padding: 0; - abbr { - display: flex; - width: 3.6rem; - height: 3.6rem; - align-items: center; - justify-content: center; - margin: auto; - font-weight: 600; - background-color: black !important; - color: white !important; - border-radius: 5rem; - } - } +export const DayText = styled.p` + font-size: 1.3rem; `; // 캘린더를 불러옴 @@ -185,18 +126,15 @@ export const ButtonContainer = styled.div` export const TimeButton = styled.button<{ isSelected?: boolean }>` padding: 1rem; font-size: 1.7rem; - background-color: #ededed; - color: black; + background-color: ${({ isSelected }) => (isSelected ? "black" : "#EDEDED")}; + color: ${({ isSelected }) => (isSelected ? "white" : "black")}; font-weight: 500; width: 100%; margin: 1.5rem auto 0 auto; flex: 1; - background-color: ${(props) => (props.isSelected ? "black" : "#EDEDED")}; - color: ${(props) => (props.isSelected ? "white" : "black")}; cursor: pointer; `; -// export const TextContainer = styled.div` width: 100%; display: flex; @@ -211,7 +149,7 @@ export const MemoText = styled.textarea` font-size: 1.6rem; font-weight: 300; line-height: 184%; - background-color: #F4F4F4; + background-color: #f4f4f4; border-radius: 1.3rem; border: none; padding: 2rem; @@ -221,14 +159,14 @@ export const MemoText = styled.textarea` border: none; } &::placeholder { - color: #AEAEB2; + color: #aeaeb2; } `; export const MemoTextLength = styled.span` width: 100%; text-align: end; - color: #AEAEB2; + color: #aeaeb2; font-size: 1.6rem; font-weight: 500; `; @@ -258,7 +196,7 @@ export const CompleteButton = styled.button` export const NavFirst = styled(Link)` font-size: 1.4rem; font-weight: 500; - color: #AEAEB2; + color: #aeaeb2; text-decoration: underline; cursor: pointer; -`; \ No newline at end of file +`; diff --git a/src/pages/applyCogo/applyCogoComplete.tsx b/src/pages/applyCogo/applyCogoComplete.tsx index 17618ab..3b7f4b5 100644 --- a/src/pages/applyCogo/applyCogoComplete.tsx +++ b/src/pages/applyCogo/applyCogoComplete.tsx @@ -16,13 +16,69 @@ import BackButton from "../../components/button/backButton"; import moment from "moment"; import { useNavigate } from "react-router-dom"; import Coffee from "../../assets/Coffee.svg"; +import axiosInstance from "../../apis/axiosInstance"; // axiosInstance 추가 export default function ApplyCogoComplete() { - const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 추가 const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 - const handleNextButton = () => { - navigate("/cogo"); + const handleNextButton = async () => { + // 로딩 상태 시작 + setIsLoading(true); + + try { + // localStorage에서 데이터 불러오기 + const mentorIdStr = localStorage.getItem("mentorId"); + const possibleDateIdStr = localStorage.getItem("possible_date_id"); + const memoText = localStorage.getItem("memoText"); + + // 데이터 존재 여부 확인 + if (!mentorIdStr || !possibleDateIdStr || !memoText) { + alert("필수 데이터가 누락되었습니다. 다시 시도해주세요."); + setIsLoading(false); + return; + } + + // 문자열을 숫자로 변환 + const mentorId = Number(mentorIdStr); + const possibleDateId = Number(possibleDateIdStr); + + // 변환된 데이터 유효성 검사 + if (isNaN(mentorId) || isNaN(possibleDateId)) { + alert("데이터 형식이 올바르지 않습니다."); + setIsLoading(false); + return; + } + + // API 요청을 위한 페이로드 준비 + const payload = { + mentorId: mentorId, + possibleDateId: possibleDateId, + memo: memoText, + }; + + console.log(payload) + // API 요청 보내기 (예시 엔드포인트 사용) + const response = await axiosInstance.post("/applications", payload); + + // 요청 성공 시 처리 + console.log("API 응답:", response.data); + alert("COGO 신청이 완료되었습니다!"); + + // 필요 시 localStorage 데이터 정리 + localStorage.removeItem("mentorId"); + localStorage.removeItem("possible_date_id"); + localStorage.removeItem("memoText"); + + // 다음 페이지로 네비게이션 + navigate("/cogo"); + } catch (error) { + console.error("API 요청 실패:", error); + alert("COGO 신청 중 오류가 발생했습니다. 다시 시도해주세요."); + } finally { + // 로딩 상태 해제 + setIsLoading(false); + } }; return ( @@ -35,7 +91,12 @@ export default function ApplyCogoComplete() { COGO를 하면서 많은 성장을 기원해요! - 코고 신청 완료하기 + + {isLoading ? "처리 중..." : "코고 신청 완료하기"} + 처음으로 돌아가기 diff --git a/src/pages/applyCogo/applyCogoMemo.tsx b/src/pages/applyCogo/applyCogoMemo.tsx index efd1432..269a60c 100644 --- a/src/pages/applyCogo/applyCogoMemo.tsx +++ b/src/pages/applyCogo/applyCogoMemo.tsx @@ -1,11 +1,4 @@ import { useState } from "react"; -import { - clubState, - nameState, - partState, - userTypeState, -} from "../../atoms/authState"; -import { useRecoilValue } from "recoil"; import * as S from "./applyCogo.styles"; import { Container, @@ -15,18 +8,22 @@ import { Title, } from "../../components/global.styles"; import BackButton from "../../components/button/backButton"; -import moment from "moment"; import { useNavigate } from "react-router-dom"; export default function ApplyCogoMemo() { - const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 추가 + const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 const navigate = useNavigate(); // 글자 수를 변경하는 함수 const handleMemoChange = (event: React.ChangeEvent) => { setMemoText(event.target.value); }; + const handleNextButton = () => { + // Save memoText to localStorage + console.log(memoText); + localStorage.setItem("memoText", memoText); + navigate("/applyCogoComplete"); }; @@ -38,14 +35,13 @@ export default function ApplyCogoMemo() { 멘토님께 드릴 메모를 적어보세요 COGO를 하면서 많은 성장을 기원해요! - - {memoText.length}/200 diff --git a/src/pages/applyCogo/applyCogoTime.tsx b/src/pages/applyCogo/applyCogoTime.tsx index fbbc5d8..813747c 100644 --- a/src/pages/applyCogo/applyCogoTime.tsx +++ b/src/pages/applyCogo/applyCogoTime.tsx @@ -1,14 +1,6 @@ -import { useState } from "react"; -import { - clubState, - nameState, - partState, - userTypeState, -} from "../../atoms/authState"; -import { useRecoilValue } from "recoil"; +import { useEffect, useState } from "react"; import * as S from "./applyCogo.styles"; import { - Container, HalfFixedButton, Header, Subtitle, @@ -17,104 +9,166 @@ import { import BackButton from "../../components/button/backButton"; import moment from "moment"; import { useNavigate } from "react-router-dom"; +import axiosInstance from "../../apis/axiosInstance"; + +type PossibleDate = { + possible_date_id: number; + date: string; + start_time: string; + end_time: string; +}; -type ValuePiece = Date | null; -type Value = ValuePiece | [ValuePiece, ValuePiece]; +type PossibleDatesData = PossibleDate[]; + +// 날짜 비교를 위한 유틸리티 함수 +function isSameDay(d1: Date, d2: Date): boolean { + return ( + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ); +} export default function ApplyCogoTime() { + // 오늘 날짜를 정규화하여 초기값으로 설정 const today = new Date(); - const todayWithoutTime = new Date( + const normalizedToday = new Date( today.getFullYear(), today.getMonth(), today.getDate() ); - const [dates, setDates] = useState([todayWithoutTime]); - const [selectedDates, setSelectedDates] = useState([ - todayWithoutTime, - ]); - const maxDate = moment(todayWithoutTime).add(13, "days").toDate(); - const attendDay = ["2023-12-03", "2023-12-13"]; // 예시 - const navigate = useNavigate(); - - const [timesPerDate, setTimesPerDate] = useState<{ [key: string]: string[] }>( - {} + const [selectedDate, setSelectedDate] = useState( + normalizedToday ); + const [selectedTime, setSelectedTime] = useState(null); + const [timesForDate, setTimesForDate] = useState([]); + const [mentorId, setMentorId] = useState(0); + const [possibleDates, setPossibleDates] = useState([]); + const navigate = useNavigate(); - const handleDateChange = (value: Date) => { - const dateString = moment(value).format("YYYY-MM-DD"); - - if (selectedDates.some((date) => date.getTime() === value.getTime())) { - // 이미 선택된 날짜라면 제거 - setSelectedDates( - selectedDates.filter((date) => date.getTime() !== value.getTime()) - ); - // 해당 날짜의 시간 정보도 함께 제거 - const updatedTimes = { ...timesPerDate }; - delete updatedTimes[dateString]; - setTimesPerDate(updatedTimes); - } else { - // 선택되지 않은 날짜라면 추가 - setSelectedDates([...selectedDates, value]); - // 날짜를 추가할 때 해당 날짜의 시간 배열 초기화 - setTimesPerDate({ - ...timesPerDate, - [dateString]: [], + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content); + setMentorId(response.data.content.mentorId); + localStorage.setItem("mentorId", mentorId.toString()); + }) + .catch((error) => { + console.error("멘토아이디 조회 실패: ", error); }); + }, []); + + const fetchPossibleDates = async () => { + try { + const response = await axiosInstance.get(`/possibleDates/${mentorId}`); + console.log("possibleDates get: ", response.data.content); + setPossibleDates(response.data.content || []); + } catch (error) { + console.error("멘토아이디 조회 실패: ", error); + alert("데이터를 가져오는 데 실패했습니다."); + setPossibleDates([]); } }; - const handleTimeClick = (date: Date, option: string) => { - const dateString = moment(date).format("YYYY-MM-DD"); - - if (timesPerDate[dateString]?.includes(option)) { - // 이미 선택된 시간대라면 배열에서 제거 - const updatedTimes = timesPerDate[dateString].filter( - (time) => time !== option - ); + useEffect(() => { + if (mentorId) { + fetchPossibleDates(); + } + }, [mentorId]); - // 시간대 배열이 비어 있으면 해당 날짜도 삭제 - if (updatedTimes.length === 0) { - setSelectedDates( - selectedDates.filter((d) => d.getTime() !== date.getTime()) + // selectedDate 또는 possibleDates가 변경될 때 timesForDate 업데이트 + useEffect(() => { + if (selectedDate) { + const dateString = moment(selectedDate).format("YYYY-MM-DD"); + const timesForSelectedDate = possibleDates + .filter((pd) => pd.date === dateString) + .map( + (pd) => `${pd.start_time.slice(0, 5)} ~ ${pd.end_time.slice(0, 5)}` ); - const updatedTimesPerDate = { ...timesPerDate }; - delete updatedTimesPerDate[dateString]; - setTimesPerDate(updatedTimesPerDate); - } else { - setTimesPerDate({ - ...timesPerDate, - [dateString]: updatedTimes, - }); - } + + setTimesForDate(timesForSelectedDate); } else { - // 선택되지 않은 시간대라면 배열에 추가 - setTimesPerDate({ - ...timesPerDate, - [dateString]: [...(timesPerDate[dateString] || []), option], - }); + setTimesForDate([]); + } + }, [selectedDate, possibleDates]); + + // 다음 14일의 날짜 배열을 생성하고 시간 부분을 제거하여 정규화 + const dates = Array.from({ length: 14 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() + i); + // 시간 부분을 0으로 설정하여 날짜를 정규화 + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + }); + + const formatDate = (date: Date) => date.getDate(); + const formatDay = (date: Date) => + date.toLocaleDateString("ko-KR", { weekday: "short" }); + + const handleDateChange = (value: Date) => { + // 선택된 날짜를 정규화하여 시간 부분 제거 + const normalizedDate = new Date( + value.getFullYear(), + value.getMonth(), + value.getDate() + ); + + if (selectedDate && isSameDay(selectedDate, normalizedDate)) { + // 이미 선택된 날짜라면 선택 해제 + setSelectedDate(null); + setSelectedTime(null); + } else { + // 새로운 날짜 선택 + setSelectedDate(normalizedDate); + setSelectedTime(null); } }; - const optionsToDisplay = [ - "10: 00 ~ 11: 00", - "11: 00 ~ 12: 00", - "12: 00 ~ 13: 00", - "13: 00 ~ 14: 00", - "14: 00 ~ 15: 00", - "15: 00 ~ 16: 00", - "16: 00 ~ 17: 00", - "17: 00 ~ 18: 00", - "18: 00 ~ 19: 00", - "19: 00 ~ 20: 00", - "20: 00 ~ 21: 00", - "21: 00 ~ 22: 00", - ]; - - // 날짜를 오름차순으로 정렬 - const sortedDates = selectedDates.sort((a, b) => a.getTime() - b.getTime()); + const handleTimeClick = (option: string) => { + if (selectedTime === option) { + // 이미 선택된 시간대라면 선택 해제 + setSelectedTime(null); + } else { + // 새로운 시간대 선택 + setSelectedTime(option); + } + }; const handleNextButton = () => { - navigate("/applyCogoMemo"); + // 필요한 데이터 전달 및 다음 화면으로 이동 + + // Find the possible_date_id + const formattedSelectedDate = selectedDate + ? moment(selectedDate).format("YYYY-MM-DD") + : null; + + const matchingPossibleDate = possibleDates.find((pd) => { + const timeOption = `${pd.start_time.slice(0, 5)} ~ ${pd.end_time.slice( + 0, + 5 + )}`; + return pd.date === formattedSelectedDate && timeOption === selectedTime; + }); + + const possible_date_id = matchingPossibleDate + ? matchingPossibleDate.possible_date_id + : null; + + console.log("Possible Date ID:", possible_date_id); + + // Store in localStorage + if (possible_date_id) { + localStorage.setItem("possible_date_id", possible_date_id.toString()); + } else { + console.warn("No matching possible_date_id found."); + } + + navigate("/applyCogoMemo", { + state: { + selectedDate: formattedSelectedDate, + selectedTime, + }, + }); }; return ( @@ -125,59 +179,53 @@ export default function ApplyCogoTime() { 멘토님과 시간을 맞춰보세요 COGO를 진행하기 편한 시간 대를 알려주세요. - - - view === "month" && (date < todayWithoutTime || date > maxDate) - } - tileClassName={({ date, view }) => { - if ( - view === "month" && - selectedDates.some((d) => d.getTime() === date.getTime()) - ) { - return "selected-date"; // 선택된 날짜에 스타일을 적용 - } - return null; - }} - formatDay={(locale, date) => moment(date).format("D")} // 일 제거 숫자만 보이게 - formatYear={(locale, date) => moment(date).format("YYYY")} // 네비게이션 눌렀을때 숫자 년도만 보이게 - formatMonthYear={(locale, date) => moment(date).format("YYYY. MM")} // 네비게이션에서 2023. 12 이렇게 보이도록 설정 - calendarType="gregory" // 일요일 부터 시작 - showNeighboringMonth={false} // 전달, 다음달 날짜 숨기기 - next2Label={null} // +1년 & +10년 이동 버튼 숨기기 - prev2Label={null} // -1년 & -10년 이동 버튼 숨기기 - minDetail="month" // 10년단위 년도 숨기기 - /> - + + + {normalizedToday.getFullYear()} + {normalizedToday.getMonth() + 1}월 + + {dates.map((date, index) => ( + handleDateChange(date)} + isSelected={selectedDate ? isSameDay(selectedDate, date) : false} + > + {formatDate(date)} + {formatDay(date)} + + ))} + - {sortedDates.map((date) => { - const dateString = moment(date).format("YYYY-MM-DD"); - return ( -
- {dateString} - - {optionsToDisplay.map((option) => ( + {selectedDate && ( +
+ + {timesForDate.length > 0 ? ( + timesForDate.map((timeOption, index) => ( handleTimeClick(date, option)} + key={index} + isSelected={selectedTime === timeOption} + onClick={() => handleTimeClick(timeOption)} > - {option} + {timeOption} - ))} - -
- ); - })} + )) + ) : ( +
+ 해당 날짜에는 가능한 시간이 없습니다. +
+ )} +
+
+ )} 다음 diff --git a/src/pages/cogo/cogo.tsx b/src/pages/cogo/cogo.tsx index 9017ace..1f270b3 100644 --- a/src/pages/cogo/cogo.tsx +++ b/src/pages/cogo/cogo.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { clubState, nameState, @@ -18,15 +18,23 @@ import BackButton from "../../components/button/backButton"; import moment from "moment"; import { useNavigate } from "react-router-dom"; import Arrow from "../../assets/ArrowRight.svg" +import axiosInstance from "../../apis/axiosInstance"; export default function Cogo() { - const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 추가 + const [userRole, setUserRole] = useState(""); const navigate = useNavigate(); - // 글자 수를 변경하는 함수 - const handleMemoChange = (event: React.ChangeEvent) => { - setMemoText(event.target.value); - }; + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content.role); + setUserRole(response.data.content.role); + }) + .catch((error) => { + console.error("Failed to fetch user data:", error); + }); + }, []); const handleSendButton = () => { navigate("/cogo/send"); @@ -48,7 +56,7 @@ export default function Cogo() { - 받은 코고 + {userRole === "MENTOR" ? "받은" : "보낸"} 코고 diff --git a/src/pages/cogo/completeCogo/completeCogo.tsx b/src/pages/cogo/completeCogo/completeCogo.tsx new file mode 100644 index 0000000..62332aa --- /dev/null +++ b/src/pages/cogo/completeCogo/completeCogo.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { + clubState, + nameState, + partState, + userTypeState, +} from "../../../atoms/authState"; +import { useRecoilValue } from "recoil"; +import * as S from "../cogo.styles"; +import { + Container, + HalfFixedButton, + Header, + Subtitle, + Title, +} from "../../../components/global.styles"; +import BackButton from "../../../components/button/backButton"; +import moment from "moment"; +import { useNavigate } from "react-router-dom"; + +export default function CompleteCogo() { + const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 추가 + const navigate = useNavigate(); + + // 글자 수를 변경하는 함수 + const handleMemoChange = (event: React.ChangeEvent) => { + setMemoText(event.target.value); + }; + + const handleDetailButton = () => { + navigate("/cogo/complete/detail"); + }; + + return ( + +
+ +
+ + 받은 코고 + COGO를 하면서 많은 성장을 기원해요! + + + + + 나는지은님의 코고신청 + 2024/07/24 + + + 나는 지은님의 코고신청 + 2024/07/24 + + +
+ ); +} + \ No newline at end of file diff --git a/src/pages/cogo/completeCogo/completeCogo_detail.tsx b/src/pages/cogo/completeCogo/completeCogo_detail.tsx new file mode 100644 index 0000000..938d34c --- /dev/null +++ b/src/pages/cogo/completeCogo/completeCogo_detail.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { + clubState, + nameState, + partState, + userTypeState, +} from "../../../atoms/authState"; +import { useRecoilValue } from "recoil"; +import * as S from "../cogo.styles"; +import { + Container, + HalfFixedButton, + Header, + Subtitle, + Title, +} from "../../../components/global.styles"; +import BackButton from "../../../components/button/backButton"; +import moment from "moment"; +import { useNavigate } from "react-router-dom"; + +export default function CompleteCogoDetail() { + const [time, setTime] = useState(""); // 메모 텍스트 상태 추가 + const navigate = useNavigate(); + + const handleNextButton = () => { + navigate("/cogo/complete"); + }; + + const options = [ + "09:00 ~ 10:00", + "10:00 ~ 11:00", + "11:00 ~ 12:00", + "13:00 ~ 14:00", + "19:00 ~ 20:00", + ]; + return ( + +
+ +
+ + 김지은님과의 코고일정입니다 + COGO를 하면서 많은 성장을 기원해요! + + + + 안녕하세요, 저는 코고 개발자 김지은입니다. 다름이 아니라, 어쩌구저쩌구 + + + + + 12/28 + + {options.map((option) => ( + setTime(option)} + > + {option} + + ))} + + + + + 거절 + 수락 + +
+ ); +} diff --git a/src/pages/cogo/sendCogo/sendCogo.tsx b/src/pages/cogo/sendCogo/sendCogo.tsx index aed30ca..2d9e07a 100644 --- a/src/pages/cogo/sendCogo/sendCogo.tsx +++ b/src/pages/cogo/sendCogo/sendCogo.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { clubState, nameState, @@ -17,15 +17,39 @@ import { import BackButton from "../../../components/button/backButton"; import moment from "moment"; import { useNavigate } from "react-router-dom"; +import axiosInstance from "../../../apis/axiosInstance"; +import { CogoData } from "../../../types/cogoData"; + +type CogoDataList = CogoData[]; export default function SendCogo() { - const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 추가 + const [userRole, setUserRole] = useState(""); + const [cogoDataList, setCogoDataList] = useState([]); const navigate = useNavigate(); - // 글자 수를 변경하는 함수 - const handleMemoChange = (event: React.ChangeEvent) => { - setMemoText(event.target.value); - }; + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content.role); + setUserRole(response.data.content.role); + }) + .catch((error) => { + console.error("Failed to fetch user data:", error); + }); + + axiosInstance + .get(`/applications/status`,{ + params: "unmatched" + }) + .then((response) => { + console.log(response.data.content); + setCogoDataList(response.data.content); + }) + .catch((error) => { + console.error("Failed to fetch role data:", error); + }); + }, []); const handleDetailButton = () => { navigate("/cogo/send/detail"); diff --git a/src/pages/cogo/sendCogo/sendCogo_detail.tsx b/src/pages/cogo/sendCogo/sendCogo_detail.tsx index 0d62a58..69822f0 100644 --- a/src/pages/cogo/sendCogo/sendCogo_detail.tsx +++ b/src/pages/cogo/sendCogo/sendCogo_detail.tsx @@ -39,7 +39,7 @@ export default function SendCogoDetail() { - 김지은님이 코고신청을 보냈어요 + 김지은님께 보낸 코고입니다. COGO를 하면서 많은 성장을 기원해요! @@ -62,48 +62,6 @@ export default function SendCogoDetail() { ))} - - 12/28 - - {options.map((option) => ( - setTime(option)} - > - {option} - - ))} - - - - 12/28 - - {options.map((option) => ( - setTime(option)} - > - {option} - - ))} - - - - 12/28 - - {options.map((option) => ( - setTime(option)} - > - {option} - - ))} - - 거절 diff --git a/src/pages/intro/intro.styles.tsx b/src/pages/intro/intro.styles.tsx new file mode 100644 index 0000000..8f51d7a --- /dev/null +++ b/src/pages/intro/intro.styles.tsx @@ -0,0 +1,21 @@ +import styled from "styled-components"; + +export const LoginContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 90%; +`; + +export const Logo = styled.img` + width: 35%; + height: auto; + margin-bottom: 10rem; +`; + +export const GoogleButton = styled.img` + width: 100%; + cursor: pointer; +`; diff --git a/src/pages/intro/intro.tsx b/src/pages/intro/intro.tsx new file mode 100644 index 0000000..eef6869 --- /dev/null +++ b/src/pages/intro/intro.tsx @@ -0,0 +1,17 @@ +import IntroAnim from "../../assets/Intro.json"; +import IntroAnim1 from "../../assets/Intro-1.json"; +import { Container } from "../../components/global.styles"; +import Lottie from "lottie-react"; + +export default function Intro() { + return ( + + + + ); +} diff --git a/src/pages/login/LoginCallback.tsx b/src/pages/login/LoginCallback.tsx index d7180a6..b8dfcaa 100644 --- a/src/pages/login/LoginCallback.tsx +++ b/src/pages/login/LoginCallback.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom"; import { authState } from "../../atoms/authState"; import styled from "styled-components"; import loadingGIF from "../../assets/loading.gif"; +import authAxiosInstanceReissue from "../../apis/authAxiosInstanceReissue"; function LoginCallback() { const setAuth = useSetRecoilState(authState); @@ -17,17 +18,7 @@ function LoginCallback() { isFetchingRef.current = true; // 요청 시작 try { - const response = await axios.post( - "https://cogo.life/reissue", - {}, - { - headers: { - "Content-Type": "application/json", - }, - withCredentials: true, - maxRedirects: 0, // 리다이렉션 방지 - } - ); + const response = await authAxiosInstanceReissue.post("/auth/reissue", {}); console.log("응답 헤더:", response.data); @@ -43,6 +34,7 @@ function LoginCallback() { }); localStorage.setItem("isLoggedIn", "true"); + localStorage.setItem("token", accessToken); switch (loginStatus) { case "signup": diff --git a/src/pages/login/login.styles.tsx b/src/pages/login/login.styles.tsx index e2b4939..dc794c3 100644 --- a/src/pages/login/login.styles.tsx +++ b/src/pages/login/login.styles.tsx @@ -1,40 +1,64 @@ -import styled from "styled-components"; +import styled, { css, keyframes } from "styled-components"; -export const ModalBackdrop = styled.div` - display: flex; - position: fixed; - justify-content: center; - align-items: center; +const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +const fadeOut = keyframes` + from { + opacity: 1; + } + to { + opacity: 0; + } +`; + +export const AnimatedContainer = styled.div<{ fadeIn?: boolean; fadeOut?: boolean }>` + animation: ${({ fadeIn: fadeInProp, fadeOut: fadeOutProp }) => + fadeInProp + ? css`${fadeIn} 0.5s forwards` + : fadeOutProp + ? css`${fadeOut} 0.5s forwards` + : "none"}; + + position: absolute; top: 0; - left: 0; - width: 100%; + max-width: 520px; height: 100%; - background: rgba(0, 0, 0, 0.3); + + z-index: ${({ fadeOut: fadeOutProp }) => (fadeOutProp ? 2 : 1)}; `; -export const ModalContainer = styled.div` - background-color: #fff; - padding: 40px; - border-radius: 15px; +export const Container = styled.div` display: flex; flex-direction: column; + justify-content: center; align-items: center; - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); + max-width: 520px; + height: 90%; `; -export const ModalHeader = styled.div` - text-align: center; - font-size: 20px; - margin: 0; - margin-bottom: 20px; +export const LoginContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 90%; `; -export const HeartImage = styled.img` - width: 150px; +export const Logo = styled.img` + width: 35%; height: auto; - margin-bottom: 25px; + margin-bottom: 10rem; `; export const GoogleButton = styled.img` + width: 100%; cursor: pointer; `; diff --git a/src/pages/login/login.tsx b/src/pages/login/login.tsx index 2162116..aeacecb 100644 --- a/src/pages/login/login.tsx +++ b/src/pages/login/login.tsx @@ -1,30 +1,57 @@ -import LoginHeart from "../../assets/heart.svg"; +import { useEffect, useState } from "react"; import GoogleBtn from "../../assets/GoogleButton.svg"; +import Logo from "../../assets/Logo.svg"; +import { Container } from "../../components/global.styles"; import * as S from "./login.styles"; +import Intro from "../intro/intro"; // 로그인 모달 const LoginModal = () => { + const [showIntro, setShowIntro] = useState(true); + const [fadeOut, setFadeOut] = useState(false); const onGoogleLogin = () => { window.location.href = "https://cogo.life/oauth2/authorization/google"; }; + useEffect(() => { + const introDuration = 2500; + const fadeDuration = 500; + + const timer = setTimeout(() => { + setFadeOut(true); + + setTimeout(() => { + setShowIntro(false); + }, fadeDuration); + }, introDuration); + + return () => { + clearTimeout(timer); + }; + }, []); + return ( - - - - 지금 가입하고 -
- 대학생 커뮤니티에 참여하세요! -
- - -
-
+ + {showIntro ? ( + + + + ) : ( + + + + + + + + + )} + ); }; diff --git a/src/pages/main/main copy.tsx b/src/pages/main/main copy.tsx index af7642f..f3e7628 100644 --- a/src/pages/main/main copy.tsx +++ b/src/pages/main/main copy.tsx @@ -196,49 +196,8 @@ function Main() { ))} - - 어떤 선배가 있을까요? - 파트별 코고 선배 알아보기 - - - - - 동아리 - {activeButtons} - Lead - - - - - {!mentorData || - (Array.isArray(mentorData) && mentorData.length === 0) - ? null - : mentorData[currentIndex].mentorName} - - { - navigate("/timeselect", { - state: { - key: - !mentorData || - (Array.isArray(mentorData) && mentorData.length === 0) - ? null - : mentorData[currentIndex].username, - }, - }); - }} - > - 코고 신청하기 - - ); diff --git a/src/pages/main/main.styles.tsx b/src/pages/main/main.styles.tsx index 5c64577..050ff1e 100644 --- a/src/pages/main/main.styles.tsx +++ b/src/pages/main/main.styles.tsx @@ -1,7 +1,7 @@ import styled from "styled-components"; export const Logo = styled.img` - height: 4rem; + height: 2.5rem; width: auto; `; @@ -38,147 +38,30 @@ export const Hr = styled.hr` border: 0.5px solid #c1c1c1; `; -export const HeaderTitleContainer = styled.div` - display: flex; - flex-direction: column; - margin: 2rem 0 -3rem 0; -`; - export const BodyContainer = styled.div` - display: flex; - width: 100%; -`; - -export const ProfileCardContainer = styled.div` - display: flex; - overflow-x: auto; - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ - &::-webkit-scrollbar { - display: none; /* Chrome , Safari , Opera */ - } - padding: 2rem 2rem 2rem 0.75rem; - gap: 3rem; -`; - -export const ProfileCard = styled.div` - border-radius: 2.2rem; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background-color: white; - // box-shadow: 0.5rem 0.5rem 2rem rgba(162, 162, 162, 0.5); - box-shadow: -0.5rem -0.6rem 0.3rem 0 rgba(239, 239, 239, 0.3), - 0.6rem 0.6rem 1.2rem 0 rgba(163, 163, 163, 0.3); - position: relative; - padding: 6.5rem 1rem 6rem 1rem; -`; - -export const ProfileClub = styled.div` - position: absolute; - transform: rotate(90deg); - color: #E0E0E0; - font-size: 2.2rem; - font-weight: 600; - letter-spacing: 0.3rem; - left: -1rem; - top: 6rem; -`; - -export const ProfileCircle = styled.div` - width: 15.5rem; - height: 15.5rem; - display: flex; - align-items: center; - justify-content: center; - background: var( - --Linear, - linear-gradient( - 236deg, - #eb4436 20.97%, - #f6b805 43.66%, - #4286f5 63.36%, - #149a5d 82.85% - ) - ); - border-radius: 50%; - margin: 0 6rem; -`; - -export const ProfileImg = styled.img` - width: 14.5rem; - height: 14.5rem; - border-radius: 50%; -`; - -export const ProfileName = styled.span` - font-size: 1.8rem; - font-weight: 600; - margin: 2.2rem 0 3.5rem 0; -`; - -export const ProfileBottomContainer = styled.div` width: 100%; - bottom: 0; + height: 90%; display: flex; - justify-content: center; - gap: 1rem; -`; - -export const ProfileIcon = styled.div` - font-weight: 500; - font-size: 1.5rem; - color: white; - background-color: black; - border-radius: 3rem; - padding: 0.65rem 2rem; -`; - -export const BodyIntroduce = styled.div` - flex: 5; - display: flex; - justify-content: space-around; - height: 50%; flex-direction: column; - margin-left: 1rem; - margin-right: 1rem; - transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out; - transform: translateX(0%); // 초기 위치 -`; - -export const BodyIntroduceHeader = styled.div` - margin-top: 10px; - font-size: 20px; - font-weight: bold; -`; -export const BodyIntroduceText = styled.div` - margin: 10px 0; - max-height: 70%; + gap: 1.75rem; + margin: -1.5rem 0 0 0; + padding: 1rem 0.75rem 1rem 0.75rem; overflow-y: auto; - font-size: 12px; -`; - -export const ApplyButton = styled.div` - display: flex; - justify-content: center; - align-items: center; - background: linear-gradient(to left, #62c6c4 0%, #02a6cb 100%); - /* width: 360px; */ - height: 50px; - border-radius: 7px; - font-size: 16px; - font-weight: bold; - color: white; - cursor: pointer; -`; -export const CardWrapper = styled.div` - display: flex; - width: 300%; - overflow: hidden; -`; - -export const Card = styled.div` - min-width: 100%; - transition: all 0.5s ease; + /* 웹킷 기반 브라우저 */ + ::-webkit-scrollbar { + width: 8px; + } + ::-webkit-scrollbar-track { + background-color: #f1f1f1; + } + ::-webkit-scrollbar-thumb { + background-color: #888; + border-radius: 5rem; + } + ::-webkit-scrollbar-thumb:hover { + background-color: #555; + } + /* 파이어폭스 */ + scrollbar-width: thin; + scrollbar-color: #888 #f1f1f1; `; diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index e9c7c1c..972c8c7 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -1,20 +1,15 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import * as S from "./main.styles"; import { useNavigate } from "react-router-dom"; import { authState } from "../../atoms/authState"; import { useRecoilState } from "recoil"; -import LeftButton from "../../assets/LeftButton.svg"; -import RightButton from "../../assets/RightButton.svg"; import axios from "axios"; import axiosInstance from "../../apis/axiosInstance"; -import { - Container, - Header, - Subtitle, - Title, -} from "../../components/global.styles"; +import { Container, Header } from "../../components/global.styles"; import Logo from "../../assets/Logo.svg"; import Search from "../../assets/Search.svg"; +import MentorCard from "../../components/mentorCard/mentorCard"; +import { MentorData } from "../../types/mentorData"; type MentorCategory = { 기획: "PM"; @@ -30,14 +25,6 @@ const mentorCategory: MentorCategory = { BE: "BE", }; -interface MentorData { - picture: string; - mentorName: string; - clubName: string[]; - field: string; - username: string; -} - const saveTokenToLocalStorage = (token: string) => { localStorage.setItem("token", token); }; @@ -50,113 +37,117 @@ const saveCategoryToLocalStorage = (category: keyof MentorCategory) => { localStorage.setItem("activeCategory", category); }; -const getCategoryFromLocalStorage = () => { - return localStorage.getItem("activeCategory") as keyof MentorCategory; +const getCategoryFromLocalStorage = (): keyof MentorCategory => { + const category = localStorage.getItem("activeCategory"); + if (category && category in mentorCategory) { + return category as keyof MentorCategory; + } + return "기획"; // 기본값 설정 }; function Main() { - const [activeButtons, setActiveButtons] = useState( - getCategoryFromLocalStorage() || "기획" + const [activeCategory, setActiveCategory] = useState( + getCategoryFromLocalStorage() ); - const [mentorData, setMentorData] = useState(null); + const [mentorData, setMentorData] = useState([]); + const [selectedMentorData, setSelectedMentorData] = useState(null); const navigate = useNavigate(); - const [currentIndex, setCurrentIndex] = useState(0); const [auth, setAuth] = useRecoilState(authState); - // useEffect(() => { - // const isLoggedIn = localStorage.getItem("isLoggedIn") === "true"; - // if (!isLoggedIn) { - // navigate("/login"); - // } else { - // const token = getTokenFromLocalStorage(); - // if (token) { - // setAuth((prevAuth) => ({ - // ...prevAuth, - // token, - // isLoggedIn: true, - // })); - // } - // } - // }, [navigate, setAuth]); - - // useEffect(() => { - // if (auth.token) { - // saveTokenToLocalStorage(auth.token); - // } - // }, [auth.token]); - - const getMentorData = () => { - const url = `/mentors/part?part=${mentorCategory[activeButtons]}`; - - axiosInstance - .get(url) - .then((response) => { - setMentorData(response.data); - console.log(response.data); - console.log("토큰 아직 유효해용~"); - }) - .catch(async (error) => { - console.error("Error:", error); - // error.response가 undefined가 아닌지 확인 - if (error.response) { - console.log("리이슈 요청 보내야 해용~"); - const originalRequest = error.config; - - // 액세스 토큰 만료 확인 - if (error.response.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - try { - // 리이슈 요청 - const response = await axios.post( - "https://cogo.life/reissue", - {}, - { - headers: { - "Content-Type": "application/json", - }, - withCredentials: true, - } - ); - - // 새로운 액세스 토큰 저장 - const newToken = response.headers.access; - saveTokenToLocalStorage(newToken); - if (newToken) { - console.log("리이슈 성공이용~", newToken); - } + useEffect(() => { + const isLoggedIn = localStorage.getItem("isLoggedIn") === "true"; + if (!isLoggedIn) { + navigate("/login"); + } else { + const token = getTokenFromLocalStorage(); + if (token) { + setAuth((prevAuth) => ({ + ...prevAuth, + token, + isLoggedIn: true, + })); + } + } + }, [navigate, setAuth]); - // 원래 요청의 헤더 업데이트 - originalRequest.headers["Authorization"] = `Bearer ${newToken}`; + useEffect(() => { + if (auth.token) { + saveTokenToLocalStorage(auth.token); + } + }, [auth.token]); + + const getMentorData = async () => { + const url = `/mentors/part?part=${mentorCategory[activeCategory]}`; + + try { + const response = await axiosInstance.get(url); + console.log("Fetched data:", response.data); + + if (response.data && Array.isArray(response.data.content)) { + setMentorData(response.data.content); + } else { + setMentorData([]); + console.error("Invalid data format:", response.data); + } + } catch (error: any) { + console.error("Error:", error); + if (error.response) { + console.log("리이슈 요청 보내야 해요"); + const originalRequest = error.config; + + if (error.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const response = await axios.post( + "https://cogo.life/reissue", + {}, + { + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + } + ); - // 원래 요청 재시도 - return axiosInstance(originalRequest); - } catch (reissueError) { - // 리이슈 요청 실패 시 - console.error("Failed to reissue access token:", reissueError); - return Promise.reject(reissueError); + const newToken = response.headers.access; + saveTokenToLocalStorage(newToken); + if (newToken) { + console.log("리이슈 성공", newToken); } - // if (error.response && error.response.status === 401) { - // localStorage.removeItem("isLoggedIn"); - // navigate("/login"); - } else { - console.log("An unexpected error occurred"); + originalRequest.headers["Authorization"] = `Bearer ${newToken}`; + + // 원래 요청 재시도 + const retryResponse = await axiosInstance(originalRequest); + // 필요한 경우 retryResponse를 처리합니다. + return retryResponse; + } catch (reissueError) { + console.error("Failed to reissue access token:", reissueError); + navigate("/login"); } } else { - // error.response가 없는 경우 - console.log("Network or unexpected error occurred"); + console.log("An unexpected error occurred"); } - }); + } else { + console.log("Network or unexpected error occurred"); + } + } }; useEffect(() => { getMentorData(); - saveCategoryToLocalStorage(activeButtons); - }, [activeButtons]); + saveCategoryToLocalStorage(activeCategory); + }, [activeCategory]); const handleButtonClick = (buttonName: keyof MentorCategory) => { - setActiveButtons(buttonName); + setActiveCategory(buttonName); + }; + + const handleProfileSelect = (mentor: MentorData) => { + setSelectedMentorData(mentor); + console.log("selectedData: ", mentor); + navigate(`/mentor-detail/${mentor.mentorId}`); }; const handleSearchBtnClick = () => { @@ -166,7 +157,7 @@ function Main() { return (
- + @@ -174,44 +165,23 @@ function Main() { {["기획", "디자인", "FE", "BE"].map((buttonName) => ( { - handleButtonClick(buttonName as keyof MentorCategory); - }} + $active={activeCategory === buttonName} + onClick={() => handleButtonClick(buttonName as keyof MentorCategory)} > {buttonName} ))} - - 어떤 선배가 있을까요? - 파트별 코고 선배 알아보기 -
- - {Array.from({ length: 3 }).map((_, index) => ( - - GDSC - - - - 나는 교휘 - - {activeButtons} - 직무직무 - 경력 - - - ))} - + {mentorData.map((mentor) => ( + + ))}
); diff --git a/src/pages/mentorProfile/mentorProfile.styles.tsx b/src/pages/mentorDetails/mentorDetails.styles.tsx similarity index 60% rename from src/pages/mentorProfile/mentorProfile.styles.tsx rename to src/pages/mentorDetails/mentorDetails.styles.tsx index 72d3f60..823d01d 100644 --- a/src/pages/mentorProfile/mentorProfile.styles.tsx +++ b/src/pages/mentorDetails/mentorDetails.styles.tsx @@ -26,53 +26,47 @@ export const BodyContainer = styled.div` export const ProfileContainer = styled.div` display: flex; + width: 100%; flex-direction: column; align-items: center; - gap: 4rem; - margin: 4rem auto 5rem auto; + gap: 2rem; + margin-top: 1.5rem; `; -export const ProfileCircle = styled.div` - width: 18rem; - height: 18rem; +export const ProfileBox = styled.div` + width: 100%; + height: 19rem; display: flex; align-items: center; justify-content: center; - background: var( - --Linear, - linear-gradient( - 236deg, - #eb4436 20.97%, - #f6b805 43.66%, - #4286f5 63.36%, - #149a5d 82.85% - ) - ); - border-radius: 50%; + background-color: #F2F2F2; margin: 0 6rem; + border-radius: 2.2rem; + overflow: hidden; `; export const ProfileImg = styled.img` - width: 17rem; - height: 17rem; - border-radius: 50%; + width: 100%; + height: auto; `; -export const ProfileBottomContainer = styled.div` - width: 100%; - bottom: 0; +export const ProfileBase = styled.img` + height: 52%; + width: auto; +`; + +export const ProfileTagContainer = styled.div` display: flex; - justify-content: center; - gap: 1rem; + gap: 1.5rem; `; -export const ProfileIcon = styled.div` +export const ProfileTag = styled.div` font-weight: 500; font-size: 1.5rem; color: white; background-color: black; border-radius: 3rem; - padding: 0.65rem 2rem; + padding: 0.75rem 2.1rem; `; export const IntroduceContainer = styled.div` @@ -83,15 +77,31 @@ export const IntroduceContainer = styled.div` `; export const IntroduceTitle = styled.span` - font-size: 2.2rem; + font-size: 2rem; font-weight: 500; - margin-bottom: 1.5rem; + margin: 4rem 0 1rem 0; `; -export const IntroduceContent = styled.span` + +export const IntroduceDescript = styled.span` font-size: 1.6rem; font-weight: 300; line-height: 184%; background-color: #F4F4F4; - border-radius: 1.7rem; - padding: 2rem; + border-radius: 1.2rem; + padding: 1.3rem; +`; + +export const IntroduceAnswer = styled.span` + font-size: 1.6rem; + font-weight: 300; + line-height: 184%; + padding: 1rem 0; +`; + +export const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 90%; `; diff --git a/src/pages/mentorDetails/mentorDetails.tsx b/src/pages/mentorDetails/mentorDetails.tsx new file mode 100644 index 0000000..a91a109 --- /dev/null +++ b/src/pages/mentorDetails/mentorDetails.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import * as S from "./mentorDetails.styles"; +import { useNavigate, useParams } from "react-router-dom"; +import axiosInstance from "../../apis/axiosInstance"; +import { + Container, + FixedButton, +} from "../../components/global.styles"; +import BackButton from "../../components/button/backButton"; +import ProfileBase from "../../assets/ProfileBase.svg"; +import { MentorDetail } from "../../types/mentorDetail"; +import Loading from "../../components/loading/loading"; + +type MentorCategory = { + 기획: "PM"; + 디자인: "DESIGN"; + FE: "FE"; + BE: "BE"; +}; + +const mentorCategory: MentorCategory = { + 기획: "PM", + 디자인: "DESIGN", + FE: "FE", + BE: "BE", +}; + +export default function MentorDetails() { + const { mentorid } = useParams(); + const [mentorData, setMentorData] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + if (mentorid) { + axiosInstance.get(`/mentors/${mentorid}`) + .then(response => { + console.log(response.data.content) + setMentorData(response.data.content); + }) + .catch(error => { + console.error("Failed to fetch mentor data:", error); + }); + } + }, []); + + const handleApplyBtnClick = () => { + navigate("/applycogotime"); + }; + + const formatTextWithLineBreaks = (text: string) => { + return text.split('\n').map((line, index) => ( + + {line} +
+
+ )); + }; + + return ( + + {mentorData ? ( + <> + + + {mentorData.mentorName} 멘토님 + + + + + {mentorData.imageUrl ? ( + <> + + + ) : ( + + )} + + + {mentorData.part} + {mentorData.club} + + + + + {mentorData.introductionTitle + ? formatTextWithLineBreaks(mentorData.introductionTitle) + : `안녕하세요`} + + + {mentorData.introductionDescription + ? formatTextWithLineBreaks(mentorData.introductionDescription) + : `${mentorData.part} 파트 멘토, ${mentorData.mentorName}입니다`} + + 이런 분야에서 멘토링이 가능해요 + + {mentorData.introductionAnswer1 + ? formatTextWithLineBreaks(mentorData.introductionAnswer1) + : `${mentorData.part} 관련 분야의 멘토링이 가능해요`} + + 이런 경험들을 해왔어요 + + {mentorData.introductionAnswer2 + ? formatTextWithLineBreaks(mentorData.introductionAnswer2) + : `${mentorData.part} 관련 경험이 있어요`} + + + 코고 신청하기 + + + ) : ( + + + + )} + + ); +} diff --git a/src/pages/mentorProfile/mentorProfile.tsx b/src/pages/mentorProfile/mentorProfile.tsx deleted file mode 100644 index df6a8d4..0000000 --- a/src/pages/mentorProfile/mentorProfile.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useEffect, useState } from "react"; -import * as S from "./mentorProfile.styles"; -import { useNavigate } from "react-router-dom"; -import { authState } from "../../atoms/authState"; -import { useRecoilState } from "recoil"; -import LeftButton from "../../assets/LeftButton.svg"; -import RightButton from "../../assets/RightButton.svg"; -import axios from "axios"; -import axiosInstance from "../../apis/axiosInstance"; -import { - Container, - FixedButton, - Header, - Subtitle, - Title, -} from "../../components/global.styles"; -import Logo from "../../assets/Logo.svg"; -import Search from "../../assets/Search.svg"; -import BackButton from "../../components/button/backButton"; - -type MentorCategory = { - 기획: "PM"; - 디자인: "DESIGN"; - FE: "FE"; - BE: "BE"; -}; - -const mentorCategory: MentorCategory = { - 기획: "PM", - 디자인: "DESIGN", - FE: "FE", - BE: "BE", -}; - -interface MentorData { - picture: string; - mentorName: string; - clubName: string[]; - field: string; - username: string; -} - -const saveTokenToLocalStorage = (token: string) => { - localStorage.setItem("token", token); -}; - -const getTokenFromLocalStorage = () => { - return localStorage.getItem("token"); -}; - -const saveCategoryToLocalStorage = (category: keyof MentorCategory) => { - localStorage.setItem("activeCategory", category); -}; - -const getCategoryFromLocalStorage = () => { - return localStorage.getItem("activeCategory") as keyof MentorCategory; -}; - -export default function MentorProfile() { - const [activeButtons, setActiveButtons] = useState( - getCategoryFromLocalStorage() || "기획" - ); - const [mentorData, setMentorData] = useState(null); - const navigate = useNavigate(); - const [currentIndex, setCurrentIndex] = useState(0); - const [auth, setAuth] = useRecoilState(authState); - - // useEffect(() => { - // const isLoggedIn = localStorage.getItem("isLoggedIn") === "true"; - // if (!isLoggedIn) { - // navigate("/login"); - // } else { - // const token = getTokenFromLocalStorage(); - // if (token) { - // setAuth((prevAuth) => ({ - // ...prevAuth, - // token, - // isLoggedIn: true, - // })); - // } - // } - // }, [navigate, setAuth]); - - // useEffect(() => { - // if (auth.token) { - // saveTokenToLocalStorage(auth.token); - // } - // }, [auth.token]); - - const getMentorData = () => { - const url = `/mentors/part?part=${mentorCategory[activeButtons]}`; - - axiosInstance - .get(url) - .then((response) => { - setMentorData(response.data); - console.log(response.data); - console.log("토큰 아직 유효해용~"); - }) - .catch(async (error) => { - console.error("Error:", error); - // error.response가 undefined가 아닌지 확인 - if (error.response) { - console.log("리이슈 요청 보내야 해용~"); - const originalRequest = error.config; - - // 액세스 토큰 만료 확인 - if (error.response.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - try { - // 리이슈 요청 - const response = await axios.post( - "https://cogo.life/reissue", - {}, - { - headers: { - "Content-Type": "application/json", - }, - withCredentials: true, - } - ); - - // 새로운 액세스 토큰 저장 - const newToken = response.headers.access; - saveTokenToLocalStorage(newToken); - if (newToken) { - console.log("리이슈 성공이용~", newToken); - } - - // 원래 요청의 헤더 업데이트 - originalRequest.headers["Authorization"] = `Bearer ${newToken}`; - - // 원래 요청 재시도 - return axiosInstance(originalRequest); - } catch (reissueError) { - // 리이슈 요청 실패 시 - console.error("Failed to reissue access token:", reissueError); - return Promise.reject(reissueError); - } - - // if (error.response && error.response.status === 401) { - // localStorage.removeItem("isLoggedIn"); - // navigate("/login"); - } else { - console.log("An unexpected error occurred"); - } - } else { - // error.response가 없는 경우 - console.log("Network or unexpected error occurred"); - } - }); - }; - - useEffect(() => { - getMentorData(); - saveCategoryToLocalStorage(activeButtons); - }, [activeButtons]); - - const handleButtonClick = (buttonName: keyof MentorCategory) => { - setActiveButtons(buttonName); - }; - - const handleApplyBtnClick = () => { - navigate("/applycogotime"); - }; - - return ( - - - - 나는 교휘 멘토님 - - - - - - - - {activeButtons} - 직무직무 - 경력 - - - - 아픈건 딱 질색이니까 - - 오늘도 아침엔 입에 빵을 물고 똑같이 하루를 시작하고 온종일 한 손엔 - 아이스 아메리카노피곤해 죽겠네 - 오늘도 아침엔 입에 빵을 물고 똑같이 하루를 시작하고 온종일 한 손엔 - 아이스 아메리카노피곤해 죽겠네 - - - 코고 신청하기 - - - ); -} diff --git a/src/pages/mypage/Introduce/introduce.styles.tsx b/src/pages/mypage/Introduce/introduce.styles.tsx index 2c0935d..ba1647c 100644 --- a/src/pages/mypage/Introduce/introduce.styles.tsx +++ b/src/pages/mypage/Introduce/introduce.styles.tsx @@ -202,12 +202,18 @@ export const TextContainer = styled.div` display: flex; flex-direction: column; gap: 1rem; - margin-top: 1rem; + margin: 0 0 3rem 0; +`; + +export const MemoTitle = styled.p` + width: 100%; + font-size: 2rem; + font-weight: 500; `; export const MemoText = styled.textarea` width: 100%; - min-height: 36rem; + height: 26rem; font-size: 1.6rem; font-weight: 300; line-height: 184%; @@ -215,6 +221,7 @@ export const MemoText = styled.textarea` border-radius: 1.3rem; border: none; padding: 2rem; + resize: none; &:focus { outline: none; box-shadow: none; diff --git a/src/pages/mypage/Introduce/introduce.tsx b/src/pages/mypage/Introduce/introduce.tsx index 7f207be..e4d70bc 100644 --- a/src/pages/mypage/Introduce/introduce.tsx +++ b/src/pages/mypage/Introduce/introduce.tsx @@ -1,33 +1,101 @@ -import { useState } from "react"; -import { - clubState, - nameState, - partState, - userTypeState, -} from "../.././../atoms/authState"; -import { useRecoilValue } from "recoil"; +import { useEffect, useState } from "react"; import * as S from "./introduce.styles"; import { - Container, HalfFixedButton, Header, Subtitle, Title, } from "../../../components/global.styles"; import BackButton from "../../../components/button/backButton"; -import moment from "moment"; -import { useNavigate } from "react-router-dom"; +import axiosInstance from "../../../apis/axiosInstance"; + +type IntroduceData = { + title: string; + description: string; + answer1: string; + answer2: string; +}; export default function Introduce() { - const [memoText, setMemoText] = useState(""); // 메모 텍스트 상태 추가 - const navigate = useNavigate(); + const [mentorId, setMentorId] = useState(0); + const [introduceData, setIntroduceData] = useState(null); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [answer1, setAnswer1] = useState(""); + const [answer2, setAnswer2] = useState(""); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content); + setMentorId(response.data.content.mentorId); + }) + .catch((error) => { + console.error("멘토아이디 조회 실패: ", error); + }); + }, []); + useEffect(() => { + axiosInstance + .get(`/mentors/${mentorId}`) + .then((response) => { + console.log(response.data.content); + setTitle(response.data.content?.introductionTitle || ""); + setDescription(response.data.content?.introductionDescription || ""); + setAnswer1(response.data.content?.introductionAnswer1 || ""); + setAnswer2(response.data.content?.introductionAnswer2 || ""); + }) + .catch((error) => { + console.error("자기소개 정보 조회 실패: ", error); + }); + }, [mentorId]); + // 글자 수를 변경하는 함수 - const handleMemoChange = (event: React.ChangeEvent) => { - setMemoText(event.target.value); + const handleTitleChange = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + const handleDescriptChange = (event: React.ChangeEvent) => { + setDescription(event.target.value); + }; + const handleAnswer1Change = (event: React.ChangeEvent) => { + setAnswer1(event.target.value); }; - const handleNextButton = () => { - navigate("/applyCogoComplete"); + const handleAnswer2Change = (event: React.ChangeEvent) => { + setAnswer2(event.target.value); + }; + + const handleEditBtn = () => { + // if (!introduceData) { + // alert("사용자 데이터를 불러오는 중입니다."); + // return; + // } + + // API 요청에 사용할 데이터 + const updatedData = { + introduction_title: title, + introduction_description: description, + introduction_answer1: answer1, + introduction_answer2: answer2, + }; + + axiosInstance + .patch(`/mentors/introductions`, updatedData) + .then((response) => { + console.log("사용자 정보 업데이트 성공:", response.data); + setIsEditing(false); + alert("정보가 성공적으로 저장되었습니다."); + setIntroduceData(response.data.content); + }) + .catch((error) => { + console.error("사용자 정보 업데이트 실패:", error); + alert("정보를 저장하는 데 실패했습니다."); + }); + }; + + const toggleEditMode = () => { + setIsEditing(!isEditing); }; return ( @@ -36,20 +104,57 @@ export default function Introduce() { - 프로필의 자기소개를 남겨주세요 - 입력하신 정보는 하단의 MY에서 수정이 가능해요 - + 멘토 소개 관리 + 코고를 신청할 멘티분들께 멘토님에 대해 소개해주세요 - 한 줄 소개 + + {isEditing && {title.length}/30} + + + 간단하게 자기소개 부탁드려요 + + {isEditing && {description.length}/200} + + + 멘토링하실 분야에 대해 자세히 알려주세요 + + {isEditing && {answer1.length}/200} + + + 프로젝트나 근무 경험이 있으시다면 알려주세요 + - {memoText.length}/200 + {isEditing && {answer2.length}/200} - 다음 + + {isEditing ? "저장하기" : "수정하기"} + ); } diff --git a/src/pages/mypage/my.tsx b/src/pages/mypage/my.tsx deleted file mode 100644 index d1690be..0000000 --- a/src/pages/mypage/my.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useEffect, useState } from "react"; -import * as styles from "./my.styles"; -import BackButton from "../../components/button/backButton"; -import { useNavigate } from "react-router-dom"; - -function MyPage() { - const [activeButtons, setActiveButtons] = useState([]); - const [Username, setUsername] = useState("멘티_User"); - const navigate = useNavigate(); - - useEffect(() => { - // 클릭한 분야 변경 시 데이터 가져오기 - }, [activeButtons]); - - const handleLogout = () => { - localStorage.setItem("isLoggedIn", "false"); - navigate("/login"); - }; - - return ( - - - - - - - - - - - {Username} 님 안녕하세요 - 내정보관리 - 버그관리 - 코고소개 - 로그아웃 - - - ); -} - -export default MyPage; diff --git a/src/pages/mypage/mypage.styles.tsx b/src/pages/mypage/mypage.styles.tsx index d8292ed..d919e9b 100644 --- a/src/pages/mypage/mypage.styles.tsx +++ b/src/pages/mypage/mypage.styles.tsx @@ -4,7 +4,7 @@ export const HeaderContainer = styled.div` display: flex; justify-content: space-between; width: 100%; - margin-bottom: 1rem; + margin-bottom: 5rem; `; export const MenotorName = styled.div` @@ -26,61 +26,63 @@ export const BodyContainer = styled.div` export const ProfileContainer = styled.div` display: flex; + width: 100%; flex-direction: column; align-items: center; - gap: 4rem; - margin: 4rem auto 5rem auto; + gap: 2rem; `; -export const ProfileCircle = styled.div` - width: 15.5rem; - height: 15.5rem; +export const ProfileBox = styled.div` + width: 100%; + height: 19rem; display: flex; align-items: center; justify-content: center; - background: var( - --Linear, - linear-gradient( - 236deg, - #eb4436 20.97%, - #f6b805 43.66%, - #4286f5 63.36%, - #149a5d 82.85% - ) - ); - border-radius: 50%; - margin: 0 6rem; + background-color: #F2F2F2; + border-radius: 2.2rem; + overflow: hidden; + position: relative; `; export const ProfileImg = styled.img` - width: 14.5rem; - height: 14.5rem; - border-radius: 50%; - background-color: white; + width: 100%; + height: auto; `; -export const ProfileBottomContainer = styled.div` +export const ProfileLayer = styled.div` width: 100%; - bottom: 0; + height: 100%; + border-radius: 2.2rem; + background-color: #FFFFFF; + opacity: 70%; + position: absolute; +`; + +export const ProfileBase = styled.img` + height: 52%; + width: auto; +`; + +export const ProfileCamera = styled.img` + position: absolute; + z-index: 3; + width: 6rem; + height: 6rem; + margin-bottom: 2rem; +`; + +export const ProfileTagContainer = styled.div` display: flex; - justify-content: center; - gap: 1rem; + gap: 1.5rem; `; -export const ProfileIcon = styled.div` +export const ProfileTag = styled.div` font-weight: 500; font-size: 1.5rem; color: white; background-color: black; border-radius: 3rem; - padding: 0.65rem 2rem; -`; - -export const IntroduceContainer = styled.div` - display: flex; - flex-direction: column; - width: 100%; - margin-bottom: 8rem; + padding: 0.75rem 2.1rem; `; export const MenuContainer = styled.div` @@ -88,6 +90,7 @@ export const MenuContainer = styled.div` display: flex; flex-direction: column; gap: 2rem; + margin-top: 4rem; `; export const MenuWrapper = styled.div` @@ -121,4 +124,13 @@ export const Hr = styled.hr` export const ArrowImg = styled.img` width: 2.2rem; height: auto; -`; \ No newline at end of file +`; + +export const UploadingOverlay = styled.div` + position: absolute; + z-index: 3; + margin-top: 6rem; + font-size: 1.5rem; + font-weight: 500; + color: #a2a2a4; +`; diff --git a/src/pages/mypage/mypage.tsx b/src/pages/mypage/mypage.tsx index 8df2bc9..4b1c8f1 100644 --- a/src/pages/mypage/mypage.tsx +++ b/src/pages/mypage/mypage.tsx @@ -1,17 +1,49 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import * as S from "./mypage.styles"; import { useNavigate } from "react-router-dom"; -import { - Container, -} from "../../components/global.styles"; -import Arrow from "../../assets/ArrowRight.svg" +import { Container } from "../../components/global.styles"; +import Arrow from "../../assets/ArrowRight.svg"; +import ProfileBase from "../../assets/ProfileBase.svg"; +import Camera from "../../assets/Camera.svg"; +import { UserData } from "../../types/userData"; +import axiosInstance from "../../apis/axiosInstance"; export default function MyPage() { - const [username, setUsername] = useState("멘티_User"); + const [userData, setUserData] = useState(null); const navigate = useNavigate(); + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); + + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content); + setUserData(response.data.content); + }) + .catch((error) => { + console.error("Failed to fetch user data:", error); + }); + }, []); + + const getUserData = async (phoneNumber: string) => { + if (phoneNumber.length < 10) return; + console.log(phoneNumber); + + try { + const response = await axiosInstance.get("/users"); + console.log(response.data.content); + setUserData(response.data.content); + } catch (error) { + console.error("전화번호 인증 실패: ", error); + alert("전화번호를 다시 입력해주세요."); + } + }; const handleLogout = () => { localStorage.setItem("isLoggedIn", "false"); + localStorage.setItem("token", ""); navigate("/login"); }; @@ -27,20 +59,138 @@ export default function MyPage() { navigate("/mypage/timeselect"); }; + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // 클라이언트 측에서 파일 크기 및 형식 검증 + const allowedTypes = ["image/jpeg", "image/jpg", "image/png"]; + if (!allowedTypes.includes(file.type)) { + alert("이미지 형식만 업로드할 수 있습니다. (jpeg, jpg, png)"); + return; + } + + if (file.size > 5 * 1024 * 1024) { + // 5MB 제한 + alert("파일 크기가 너무 큽니다. 5MB 이하의 이미지를 선택해주세요."); + return; + } + + setIsUploading(true); // 업로드 시작 + setUploadError(""); // 기존 에러 초기화 + + try { + // 1단계: S3로 이미지 업로드 + const directory = "v2"; + const formData = new FormData(); + formData.append("image", file); + + const s3Response = await axiosInstance.post( + `/s3/${directory}`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + } + ); + + console.log("S3 Response:", s3Response.data); + + if (s3Response.status === 201) { + // S3 업로드 성공, 응답에서 이미지 URL 추출 + // 실제 응답 구조에 맞게 수정 필요 + let imageUrl: string | undefined; + + if (s3Response.data.content.savedUrl) { + imageUrl = s3Response.data.content.savedUrl; + } else if (s3Response.data.content.additionalProp1) { + imageUrl = s3Response.data.content.additionalProp1; + } else { + throw new Error("이미지 URL을 받아오지 못했습니다."); + } + + // 2단계: 사용자 프로필에 이미지 URL 업데이트 + const pictureResponse = await axiosInstance.put( + "/users/picture", + imageUrl + ); + + if (pictureResponse) { + console.log( + "프로필 이미지 업데이트 성공:", + pictureResponse.data.content.picture.slice(1,-1) + ); + const imageUrl = pictureResponse.data.content.picture; + setUserData((prevData) => + prevData ? { ...prevData, picture: imageUrl } : prevData + ); + alert("프로필 이미지가 성공적으로 업로드되었습니다."); + } else { + throw new Error("프로필 이미지 업데이트에 실패했습니다."); + } + } else { + throw new Error("S3 업로드에 실패했습니다."); + } + } catch (error: any) { + console.error("이미지 업로드 실패:", error); + setUploadError("이미지 업로드에 실패했습니다. 다시 시도해주세요."); + alert("이미지 업로드에 실패했습니다."); + } finally { + setIsUploading(false); // 업로드 종료 + // 파일 입력 초기화 + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + return ( + + - {username} + {userData?.name} 멘토님 - - - - - BE - 동아리 이름 - + !isUploading && fileInputRef.current?.click()} + style={{ position: "relative", cursor: "pointer" }} + > + {userData?.picture ? ( + <> + + + + ) : ( + + )} + + {isUploading ? ( + 이미지 업로드 중... + ) : ( + + {!userData?.picture + ? "프로필 이미지 등록하기" + : "프로필 이미지 변경하기"} + + )} + + + {userData?.part} + {userData?.club} + @@ -60,7 +210,7 @@ export default function MyPage() { 로그아웃 - + diff --git a/src/pages/mypage/myprofile/myprofile.tsx b/src/pages/mypage/myprofile/myprofile.tsx index aeef5fe..914c233 100644 --- a/src/pages/mypage/myprofile/myprofile.tsx +++ b/src/pages/mypage/myprofile/myprofile.tsx @@ -1,20 +1,49 @@ import { useEffect, useState } from "react"; -import { phoneNumberState } from "../../../atoms/authState"; -import { useRecoilState } from "recoil"; import * as S from "../../signup/signup.styles"; import axiosInstance from "../../../apis/axiosInstance"; -import { useNavigate } from "react-router-dom"; -import { Container, Header, BodyContainer } from "../../../components/global.styles"; +import { + Container, + Header, + BodyContainer, + HalfFixedButton, +} from "../../../components/global.styles"; import BackButton from "../../../components/button/backButton"; +import authAxiosInstance from "../../../apis/authAxiosInstance"; +import { UserData } from "../../../types/userData"; export default function MyProfile() { - const [phoneNumber, setPhoneNumber] = useRecoilState(phoneNumberState); - const [verificationCode, setVerificationCode] = useState(""); - const [isVerificationSent, setIsVerificationSent] = useState(false); - const [code, setCode] = useState(""); - const [isCodeVerified, setIsCodeVerified] = useState(false); - const [timer, setTimer] = useState(180); - const navigate = useNavigate(); + const [userData, setUserData] = useState(null); + const [name, setName] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [phoneCode, setPhoneCode] = useState(""); + const [phoneVerificationCode, setPhoneVerificationCode] = useState(""); + const [isPhoneVericationSent, setIsPhoneVericationSent] = useState(false); + const [email, setEmail] = useState(""); + const [emailCode, setEmailCode] = useState(""); + const [emailVerificationCode, setEmailVerificationCode] = useState(""); + const [isEmailVericationSent, setIsEmailVericationSent] = useState(false); + const [isPhoneCodeVerified, setIsPhoneCodeVerified] = useState(false); + const [isEmailCodeVerified, setIsEmailCodeVerified] = useState(false); + const [phoneTimer, setPhoneTimer] = useState(180); + const [emailTimer, setEmailTimer] = useState(180); + const [isEditing, setIsEditing] = useState(false); + const [emailError, setEmailError] = useState(""); + + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content); + setUserData(response.data.content); + }) + .catch((error) => { + console.error("Failed to fetch user data:", error); + }); + }, []); + + const handleNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + }; const handlePhoneChange = (e: { target: { value: string } }) => { const numbersOnly = e.target.value.replace(/\D/g, ""); @@ -23,11 +52,32 @@ export default function MyProfile() { } }; - const handleVerificationCodeChange = ( - e: React.ChangeEvent - ) => { + const handleEmailChange = (e: React.ChangeEvent) => { + const inputEmail = e.target.value; + setEmail(inputEmail); + + if (!isValidEmail(inputEmail)) { + setEmailError("유효한 이메일 형식이 아닙니다."); + } else { + setEmailError(""); + } + }; + + // 이메일 유효성 검사 함수 + const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handlePhoneCodeChange = (e: React.ChangeEvent) => { if (e.target.value.length <= 4) { - setVerificationCode(e.target.value); + setPhoneCode(e.target.value); + } + }; + + const handleEmailCodeChange = (e: React.ChangeEvent) => { + if (e.target.value.length <= 6) { + setEmailCode(e.target.value); } }; @@ -44,40 +94,88 @@ export default function MyProfile() { } }; - // 인증번호 전송 함수 - const sendVerification = async () => { + // 전화번호 인증번호 전송 함수 + const phoneSendVerification = async (phoneNumber: string) => { if (phoneNumber.length < 10) return; - setIsVerificationSent(true); - - const params = { - params: phoneNumber, - }; + console.log(phoneNumber); try { - const response = await axiosInstance.get("/users/sms", params); - console.log(response.data); - setCode(response.data.verificationCode); + const response = await axiosInstance.get("/users/sms", { + params: { + phoneNum: phoneNumber, + }, + }); + if (response.status === 200) { + console.log(response.data.content); + setPhoneVerificationCode(response.data.content.verificationCode); + setIsPhoneVericationSent(true); + setPhoneTimer(180); // 타이머 초기화 + } } catch (error) { + setIsPhoneVericationSent(false); console.error("전화번호 인증 실패: ", error); alert("전화번호를 다시 입력해주세요."); } }; - // 인증번호 재전송 함수 - const reSendVerification = () => { - setTimer(180); - setVerificationCode(""); - sendVerification(); + // 전화번호 인증번호 재전송 함수 + const phoneResendVerification = () => { + setPhoneTimer(180); + setPhoneCode(""); + phoneSendVerification(phoneNumber); + }; + + // 이메일 인증번호 전송 함수 + const emailSendVerification = async (email: string) => { + if (!isValidEmail(email)) return; + try { + const response = await authAxiosInstance.get("/auth/email", { + params: { + email: email, + }, + }); + if (response.status === 200) { + console.log(response.data.content); + setEmailVerificationCode(response.data.content.code); + setIsEmailVericationSent(true); + setEmailTimer(180); // 타이머 초기화 + } + } catch (error) { + setIsEmailVericationSent(false); + console.error("이메일 인증 실패: ", error); + alert("이메일을 다시 입력해주세요."); + } + }; + + // 전화번호 인증번호 재전송 함수 + const emailResendVerification = () => { + setEmailTimer(180); + setEmailCode(""); + emailSendVerification(email); }; // 인증번호 확인 함수 - const verifyCode = () => { - // 인증번호 확인하는 로직 - setIsCodeVerified(true); + const verifyPhoneCode = () => { + if (phoneCode === phoneVerificationCode) { + setIsPhoneCodeVerified(true); + alert("전화번호 인증이 완료되었습니다."); + } else { + setIsPhoneCodeVerified(false); + alert("인증번호가 일치하지 않습니다."); + } + }; + const verifyEmailCode = () => { + if (emailCode === emailVerificationCode) { + setIsEmailCodeVerified(true); + alert("이메일 인증이 완료되었습니다."); + } else { + setIsEmailCodeVerified(false); + alert("인증번호가 일치하지 않습니다."); + } }; - //타이머 함수 - const formatTime = () => { + // 타이머 포맷 함수 + const formatTime = (timer: number) => { const minutes = Math.floor(timer / 60); const seconds = timer % 60; return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`; @@ -85,12 +183,30 @@ export default function MyProfile() { useEffect(() => { let interval: NodeJS.Timeout; - if (isVerificationSent) { + if (isPhoneVericationSent) { + interval = setInterval(() => { + setPhoneTimer((prevTimer) => { + if (prevTimer <= 1) { + clearInterval(interval); + setIsPhoneVericationSent(false); + return 0; + } + return prevTimer - 1; + }); + }, 1000); + } + + return () => clearInterval(interval); + }, [isPhoneVericationSent]); + + useEffect(() => { + let interval: NodeJS.Timeout; + if (isEmailVericationSent) { interval = setInterval(() => { - setTimer((prevTimer) => { + setEmailTimer((prevTimer) => { if (prevTimer <= 1) { clearInterval(interval); - setIsVerificationSent(false); + setIsEmailVericationSent(false); return 0; } return prevTimer - 1; @@ -99,10 +215,53 @@ export default function MyProfile() { } return () => clearInterval(interval); - }, [isVerificationSent]); + }, [isEmailVericationSent]); + + // 전화번호와 이메일이 변경되었는지 확인 + const phoneChanged = phoneNumber !== "" && userData ? phoneNumber !== userData.phoneNum : false; + const emailChanged = email !== "" && userData ? email !== userData.email : false; + + // "저장" 버튼 활성화 조건 정의 + const isSaveDisabled = isEditing && ( + (phoneChanged && !isPhoneCodeVerified) || + (emailChanged && !isEmailCodeVerified) + ); + + const handleEditBtn = () => { + if (!userData) { + alert("사용자 데이터를 불러오는 중입니다."); + return; + } + + // 빈 필드는 userData의 기존 값으로 대체 + const updatedName = name.trim() === "" ? userData.name : name; + const updatedPhoneNumber = + phoneNumber.trim() === "" ? userData.phoneNum : phoneNumber; + const updatedEmail = email.trim() === "" ? userData.email : email; + + // API 요청에 사용할 데이터 + const updatedData = { + name: updatedName, + phoneNum: updatedPhoneNumber, + email: updatedEmail, + }; + + axiosInstance + .patch("/users", updatedData) + .then((response) => { + console.log("사용자 정보 업데이트 성공:", response.data); + setIsEditing(false); // 저장 후 편집 모드 종료 + alert("정보가 성공적으로 저장되었습니다."); + setUserData(response.data.content); // 서버에서 반환한 최신 데이터로 업데이트 + }) + .catch((error) => { + console.error("사용자 정보 업데이트 실패:", error); + alert("정보를 저장하는 데 실패했습니다."); + }); + }; - const handleNext = () => { - navigate("/mypage"); + const toggleEditMode = () => { + setIsEditing(!isEditing); }; return ( @@ -116,51 +275,155 @@ export default function MyProfile() { 개인정보는 정보통신망법에 따라 안전하게 보관됩니다 + + + {/* 이름 */} + 이름 + {isEditing ? ( + + ) : ( + {userData?.name || name} + )} + + + + {/* 휴대폰 번호 */} 휴대폰 번호 - + {isEditing ? ( + + ) : ( + + {displayFormattedPhoneNumber( + userData?.phoneNum || phoneNumber + )} + + )} - { - !isVerificationSent ? sendVerification() : reSendVerification(); - }} - style={ - phoneNumber.length === 11 - ? { color: "white", backgroundColor: "black" } - : {} - } - disabled={phoneNumber.length < 11} - > - {!isVerificationSent ? "인증번호 받기" : "재전송"} - + {isEditing && + phoneNumber !== userData?.phoneNum && + phoneNumber !== "" && ( + { + !isPhoneVericationSent + ? phoneSendVerification(phoneNumber) + : phoneResendVerification(); + }} + style={ + phoneNumber.length === 11 + ? { color: "white", backgroundColor: "black" } + : {} + } + disabled={phoneNumber.length < 11} + > + {!isPhoneVericationSent ? "인증번호 받기" : "재전송"} + + )} - {isVerificationSent && ( + + {/* 인증번호 입력 */} + {isPhoneVericationSent && isEditing && ( + + + 인증번호 + + +
+ {!isPhoneCodeVerified && ( + {formatTime(phoneTimer)} + )} + + 확인 + +
+
+ )} + + {/* 이메일 */} + + + 이메일 주소 + {isEditing ? ( + + ) : ( + {userData?.email || email} + )} + + {isEditing && email !== userData?.email && email !== "" && ( + { + !isEmailVericationSent + ? emailSendVerification(email) + : emailResendVerification(); + }} + style={ + isValidEmail(email) + ? { color: "white", backgroundColor: "black" } + : {} + } + disabled={!isValidEmail(email)} + > + {!isEmailVericationSent ? "인증번호 받기" : "재전송"} + + )} + + {emailError && {emailError}} + + {/* 인증번호 입력 */} + {isEmailVericationSent && isEditing && ( 인증번호
- {formatTime()} + {!isEmailCodeVerified && ( + {formatTime(emailTimer)} + )} 확인 @@ -168,8 +431,17 @@ export default function MyProfile() { )} - {isCodeVerified && 다음} + {name !== "" || email !== "" || phoneNumber !== "" || !isEditing ? ( + + {isEditing ? "저장하기" : "수정하기"} + + ) : ( + <> + )} ); } diff --git a/src/pages/mypage/timeselect/timeselect.styles.tsx b/src/pages/mypage/timeselect/timeselect.styles.tsx index 2c0935d..650b239 100644 --- a/src/pages/mypage/timeselect/timeselect.styles.tsx +++ b/src/pages/mypage/timeselect/timeselect.styles.tsx @@ -113,6 +113,35 @@ export const StyledCalendarWrapper = styled.div` border-radius: 5rem; } } + /* 날짜만 선택되고 시간대는 선택되지 않은 경우 */ + .date-selected-no-time abbr { + display: flex; + width: 3.6rem; + height: 3.6rem; + align-items: center; + justify-content: center; + margin: auto; + font-weight: 600; + border: 1px solid black; + border-radius: 5rem; + color: black !important; + background-color: transparent !important; + } + + /* 시간대가 선택된 날짜 */ + .date-selected-with-time abbr { + display: flex; + width: 3.6rem; + height: 3.6rem; + align-items: center; + justify-content: center; + margin: auto; + font-weight: 600; + border-radius: 5rem; + color: white !important; + background-color: black !important; + } + `; // 캘린더를 불러옴 @@ -134,8 +163,23 @@ export const Title = styled.h1` `; export const Subtitle = styled.p` - font-size: 1.6rem; - color: #aeaeb2; + font-size: 1.8rem; + color: #5e5e5e; + margin: 0 0 -0.5rem 0.5rem; +`; + +export const CircleSubtitle = styled.p` + font-size: 1.9rem; + font-weight: 500; + color: white; + background-color: black; + width: 6.5rem; + height: 6.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; `; @@ -176,10 +220,6 @@ export const ButtonContainer = styled.div` column-gap: 1.5rem; margin-top: 1rem; margin-bottom: 2rem; - - & > :nth-last-child(1):nth-child(odd) { - grid-column: span 2; // 마지막 요소가 가득 차도록 설정 - } `; export const TimeButton = styled.button<{ isSelected?: boolean }>` @@ -261,4 +301,10 @@ export const NavFirst = styled(Link)` color: #AEAEB2; text-decoration: underline; cursor: pointer; -`; \ No newline at end of file +`; + +export const NoDatesMessage = styled.p` + width: 100%; + font-size: 1.5rem; + font-weight: 500; +` \ No newline at end of file diff --git a/src/pages/mypage/timeselect/timeselect.tsx b/src/pages/mypage/timeselect/timeselect.tsx index f7463da..28d853a 100644 --- a/src/pages/mypage/timeselect/timeselect.tsx +++ b/src/pages/mypage/timeselect/timeselect.tsx @@ -1,11 +1,4 @@ -import { useState } from "react"; -import { - clubState, - nameState, - partState, - userTypeState, -} from "../../../atoms/authState"; -import { useRecoilValue } from "recoil"; +import { useEffect, useMemo, useState } from "react"; import * as S from "./timeselect.styles"; import { Container, @@ -16,10 +9,28 @@ import { } from "../../../components/global.styles"; import BackButton from "../../../components/button/backButton"; import moment from "moment"; -import { useNavigate } from "react-router-dom"; +import axiosInstance from "../../../apis/axiosInstance"; + +interface PossibleDate { + possible_date_id: number; + date: string; + start_time: string; + end_time: string; +} -type ValuePiece = Date | null; -type Value = ValuePiece | [ValuePiece, ValuePiece]; +interface UpdatedDate { + date: string; + start_time: string; + end_time: string; +} + +interface TimeSlot { + start_time: string; + end_time: string; +} + +type PossibleDatesData = PossibleDate[]; +type UpdatedDatesData = UpdatedDate[]; export default function TimeSelect() { const today = new Date(); @@ -28,93 +39,176 @@ export default function TimeSelect() { today.getMonth(), today.getDate() ); - const [dates, setDates] = useState([todayWithoutTime]); - const [selectedDates, setSelectedDates] = useState([ - todayWithoutTime, - ]); const maxDate = moment(todayWithoutTime).add(13, "days").toDate(); - const attendDay = ["2023-12-03", "2023-12-13"]; // 예시 - const navigate = useNavigate(); + const [possibleDates, setPossibleDates] = useState([]); + const [isEditing, setIsEditing] = useState(false); + const [mentorId, setMentorId] = useState(""); + const [timesPerDate, setTimesPerDate] = useState<{ + [key: string]: TimeSlot[]; + }>({}); + const [currentSelectedDate, setCurrentSelectedDate] = useState(null); - const [timesPerDate, setTimesPerDate] = useState<{ [key: string]: string[] }>( - {} - ); + const fetchPossibleDates = async () => { + try { + const response = await axiosInstance.get(`/possibleDates/${mentorId}`); + console.log("possibleDates get: ", response.data.content); + setPossibleDates(response.data.content || []); + } catch (error) { + console.error("멘토아이디 조회 실패: ", error); + alert("데이터를 가져오는 데 실패했습니다."); + setPossibleDates([]); + } + }; + + useEffect(() => { + axiosInstance + .get(`/users`) + .then((response) => { + console.log(response.data.content); + setMentorId(response.data.content.mentorId); + }) + .catch((error) => { + console.error("Failed to fetch user data:", error); + }); + }, []); + + useEffect(() => { + if (mentorId) { + fetchPossibleDates(); + } + }, [mentorId]); const handleDateChange = (value: Date) => { const dateString = moment(value).format("YYYY-MM-DD"); - if (selectedDates.some((date) => date.getTime() === value.getTime())) { - // 이미 선택된 날짜라면 제거 - setSelectedDates( - selectedDates.filter((date) => date.getTime() !== value.getTime()) - ); - // 해당 날짜의 시간 정보도 함께 제거 - const updatedTimes = { ...timesPerDate }; - delete updatedTimes[dateString]; - setTimesPerDate(updatedTimes); - } else { - // 선택되지 않은 날짜라면 추가 - setSelectedDates([...selectedDates, value]); - // 날짜를 추가할 때 해당 날짜의 시간 배열 초기화 + // 현재 클릭된 날짜를 업데이트 + setCurrentSelectedDate(value); + }; + + const handleTimeClick = (date: Date, option: string) => { + const dateString = moment(date).format("YYYY-MM-DD"); + const [start_time, end_time] = option.split(" ~ ").map((s) => s.trim()); + + if (!timesPerDate[dateString]) { setTimesPerDate({ ...timesPerDate, - [dateString]: [], + [dateString]: [{ start_time, end_time }], }); + return; } - }; - const handleTimeClick = (date: Date, option: string) => { - const dateString = moment(date).format("YYYY-MM-DD"); + const existingIndex = timesPerDate[dateString].findIndex( + (slot) => slot.start_time === start_time && slot.end_time === end_time + ); - if (timesPerDate[dateString]?.includes(option)) { + if (existingIndex !== -1) { // 이미 선택된 시간대라면 배열에서 제거 - const updatedTimes = timesPerDate[dateString].filter( - (time) => time !== option - ); + const updatedSlots = [...timesPerDate[dateString]]; + updatedSlots.splice(existingIndex, 1); - // 시간대 배열이 비어 있으면 해당 날짜도 삭제 - if (updatedTimes.length === 0) { - setSelectedDates( - selectedDates.filter((d) => d.getTime() !== date.getTime()) - ); + if (updatedSlots.length === 0) { + // 시간대 배열이 비어 있으면 해당 날짜도 삭제 const updatedTimesPerDate = { ...timesPerDate }; delete updatedTimesPerDate[dateString]; setTimesPerDate(updatedTimesPerDate); } else { setTimesPerDate({ ...timesPerDate, - [dateString]: updatedTimes, + [dateString]: updatedSlots, }); } } else { // 선택되지 않은 시간대라면 배열에 추가 setTimesPerDate({ ...timesPerDate, - [dateString]: [...(timesPerDate[dateString] || []), option], + [dateString]: [...timesPerDate[dateString], { start_time, end_time }], }); } }; + console.log("timesPerDate: ", timesPerDate); const optionsToDisplay = [ - "10: 00 ~ 11: 00", - "11: 00 ~ 12: 00", - "12: 00 ~ 13: 00", - "13: 00 ~ 14: 00", - "14: 00 ~ 15: 00", - "15: 00 ~ 16: 00", - "16: 00 ~ 17: 00", - "17: 00 ~ 18: 00", - "18: 00 ~ 19: 00", - "19: 00 ~ 20: 00", - "20: 00 ~ 21: 00", - "21: 00 ~ 22: 00", + "10:00 ~ 11:00", + "11:00 ~ 12:00", + "12:00 ~ 13:00", + "13:00 ~ 14:00", + "14:00 ~ 15:00", + "15:00 ~ 16:00", + "16:00 ~ 17:00", + "17:00 ~ 18:00", + "18:00 ~ 19:00", + "19:00 ~ 20:00", + "20:00 ~ 21:00", + "21:00 ~ 22:00", ]; - // 날짜를 오름차순으로 정렬 - const sortedDates = selectedDates.sort((a, b) => a.getTime() - b.getTime()); + // possibleDates를 날짜별로 그룹화하고, 각 그룹 내의 시간대를 정렬 + const groupedPossibleDates = useMemo(() => { + if (!possibleDates || possibleDates.length === 0) { + return { grouped: {}, sortedDates: [] }; + } + + // 그룹화 + const grouped = possibleDates.reduce((acc, curr) => { + if (!acc[curr.date]) { + acc[curr.date] = []; + } + acc[curr.date].push(curr); + return acc; + }, {} as { [key: string]: PossibleDate[] }); + + // 날짜 오름차순 정렬 + const sortedDates = Object.keys(grouped).sort( + (a, b) => new Date(a).getTime() - new Date(b).getTime() + ); + + // 각 날짜 내의 시간대 정렬 (오름차순) + sortedDates.forEach((date) => { + grouped[date].sort((a, b) => a.start_time.localeCompare(b.start_time)); + }); + + return { grouped, sortedDates }; + }, [possibleDates]); + + const handleEditBtn = async () => { + if (!possibleDates) { + alert("사용자 데이터를 불러오는 중입니다."); + return; + } - const handleNextButton = () => { - navigate("/applyCogoMemo"); + // API 요청에 사용할 데이터 + const updatedDatesData: UpdatedDatesData = []; + + Object.keys(timesPerDate).forEach((dateStr) => { + timesPerDate[dateStr].forEach((slot) => { + updatedDatesData.push({ + date: dateStr, + start_time: slot.start_time, + end_time: slot.end_time, + }); + }); + }); + console.log("updatedDatesData", updatedDatesData); + + try { + const response = await axiosInstance.post( + `/possibleDates`, + updatedDatesData + ); + console.log("사용자 정보 업데이트 성공:", response.data); + setIsEditing(false); + alert("정보가 성공적으로 저장되었습니다."); + + // 최신 데이터를 다시 가져옵니다. + await fetchPossibleDates(); + } catch (error) { + console.error("사용자 정보 업데이트 실패:", error); + alert("정보를 저장하는 데 실패했습니다."); + } + }; + + const toggleEditMode = () => { + setIsEditing(!isEditing); }; return ( @@ -125,61 +219,103 @@ export default function TimeSelect() { COGO 시간 COGO를 진행하기 편한 시간 대를 알려주세요. - - - view === "month" && (date < todayWithoutTime || date > maxDate) - } - tileClassName={({ date, view }) => { - if ( - view === "month" && - selectedDates.some((d) => d.getTime() === date.getTime()) - ) { - return "selected-date"; // 선택된 날짜에 스타일을 적용 - } - return null; - }} - formatDay={(locale, date) => moment(date).format("D")} // 일 제거 숫자만 보이게 - formatYear={(locale, date) => moment(date).format("YYYY")} // 네비게이션 눌렀을때 숫자 년도만 보이게 - formatMonthYear={(locale, date) => moment(date).format("YYYY. MM")} // 네비게이션에서 2023. 12 이렇게 보이도록 설정 - calendarType="gregory" // 일요일 부터 시작 - showNeighboringMonth={false} // 전달, 다음달 날짜 숨기기 - next2Label={null} // +1년 & +10년 이동 버튼 숨기기 - prev2Label={null} // -1년 & -10년 이동 버튼 숨기기 - minDetail="month" // 10년단위 년도 숨기기 - /> - - - {sortedDates.map((date) => { - const dateString = moment(date).format("YYYY-MM-DD"); - return ( -
- {dateString} - - {optionsToDisplay.map((option) => ( - handleTimeClick(date, option)} - > - {option} - - ))} - -
- ); - })} -
+ {isEditing ? ( + <> + + + view === "month" && + (date < todayWithoutTime || date > maxDate) + } + tileClassName={({ date, view }) => { + if (view === "month") { + const dateTime = date.getTime(); + const dateString = moment(date).format("YYYY-MM-DD"); + + if (timesPerDate[dateString] && timesPerDate[dateString].length > 0) { + // 시간대가 선택된 날짜 + return "date-selected-with-time"; + } else if (currentSelectedDate && currentSelectedDate.getTime() === dateTime) { + // 현재 클릭된 날짜 (시간대 선택 없음) + return "date-selected-no-time"; + } + } + return null; + }} + formatDay={(locale, date) => moment(date).format("D")} // 일 제거 숫자만 보이게 + formatYear={(locale, date) => moment(date).format("YYYY")} // 네비게이션 눌렀을때 숫자 년도만 보이게 + formatMonthYear={(locale, date) => + moment(date).format("YYYY. MM") + } // 네비게이션에서 2023. 12 이렇게 보이도록 설정 + calendarType="gregory" // 일요일 부터 시작 + showNeighboringMonth={false} // 전달, 다음달 날짜 숨기기 + next2Label={null} // +1년 & +10년 이동 버튼 숨기기 + prev2Label={null} // -1년 & -10년 이동 버튼 숨기기 + minDetail="month" // 10년단위 년도 숨기기 + /> + + {currentSelectedDate && ( + +
+ {moment(currentSelectedDate).format("YYYY-MM-DD")} + + {optionsToDisplay.map((option) => ( + + `${slot.start_time} ~ ${slot.end_time}` === + option + ) || false + } + onClick={() => handleTimeClick(currentSelectedDate, option)} + > + {option} + + ))} + +
+
+ )} + + ) : ( + <> + {possibleDates.length > 0 ? ( + + {groupedPossibleDates.sortedDates.map(date => ( +
+ {moment(date).format("M/D")} + + {groupedPossibleDates.grouped[date].map(slot => ( + + {`${slot.start_time.slice(0, 5)} ~ ${slot.end_time.slice(0, 5)}`} + + ))} + +
+ ))} +
+ ) : ( + + 코고를 진행 가능한 날짜가 없습니다. + + )} + + )}
- 다음 + {isEditing ? "저장하기" : "수정하기"} ); diff --git a/src/pages/search/search.styles.tsx b/src/pages/search/search.styles.tsx index e49df0a..283239d 100644 --- a/src/pages/search/search.styles.tsx +++ b/src/pages/search/search.styles.tsx @@ -116,9 +116,39 @@ export const HeaderTitleContainer = styled.div` `; export const BodyContainer = styled.div` + width: 100%; + height: 90%; display: flex; flex-direction: column; + gap: 1.75rem; + margin: -1.5rem 0 0 0; + padding: 1rem 0.75rem 1rem 0.75rem; + overflow-y: auto; + /* 웹킷 기반 브라우저 */ + ::-webkit-scrollbar { + width: 8px; + } + ::-webkit-scrollbar-track { + background-color: #f1f1f1; + } + ::-webkit-scrollbar-thumb { + background-color: #888; + border-radius: 5rem; + } + ::-webkit-scrollbar-thumb:hover { + background-color: #555; + } + /* 파이어폭스 */ + scrollbar-width: thin; + scrollbar-color: #888 #f1f1f1; +`; + +export const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; width: 100%; + height: 90%; `; export const ProfileCardContainer = styled.div` diff --git a/src/pages/search/search.tsx b/src/pages/search/search.tsx index ba680ea..85ba7f4 100644 --- a/src/pages/search/search.tsx +++ b/src/pages/search/search.tsx @@ -1,18 +1,11 @@ -import React, { useEffect, useState } from "react"; +import { useEffect } from "react"; import * as S from "./search.styles"; import { useNavigate } from "react-router-dom"; import { useRecoilState } from "recoil"; -import LeftButton from "../../assets/LeftButton.svg"; -import RightButton from "../../assets/RightButton.svg"; -import axios from "axios"; -import axiosInstance from "../../apis/axiosInstance"; import { Container, Header, - Subtitle, - Title, } from "../../components/global.styles"; -import Logo from "../../assets/Logo.svg"; import SearchIcon from "../../assets/Search.svg"; import BackButton from "../../components/button/backButton"; import TagDelete from "../../assets/TagDelete.svg"; @@ -23,7 +16,6 @@ export default function Search() { const [part, setPart] = useRecoilState(partSearchState); const [club, setClub] = useRecoilState(clubSearchState); - // 페이지가 로드될 때 localStorage에서 part와 club 값을 불러옴 useEffect(() => { setPart(""); setClub(""); @@ -33,7 +25,14 @@ export default function Search() { if (club === "" && part === "") { alert("검색할 파트나 동아리를 선택해주세요."); } else { - navigate("/search/searchview"); + const params = new URLSearchParams(); + if (part !== "") { + params.append("part", part); + } + if (club !== "") { + params.append("club", club); + } + navigate(`/search/searchview?${params.toString()}`); } }; @@ -45,6 +44,14 @@ export default function Search() { setClub(""); }; + const togglePart = (option: string) => { + setPart(part === option ? "" : option); + }; + + const toggleClub = (option: string) => { + setClub(club === option ? "" : option); + }; + return (
@@ -71,14 +78,14 @@ export default function Search() { />
- + 파트 - {["FE", "BE", "기획", "디자인"].map((option) => ( + {["FE", "BE", "PM", "DESIGN"].map((option) => ( setPart(option)} + onClick={() => togglePart(option)} > {option} @@ -90,7 +97,7 @@ export default function Search() { setClub(option)} + onClick={() => toggleClub(option)} > {option} diff --git a/src/pages/search/searchView.tsx b/src/pages/search/searchView.tsx index 952d840..04eaedc 100644 --- a/src/pages/search/searchView.tsx +++ b/src/pages/search/searchView.tsx @@ -1,21 +1,16 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import * as S from "./search.styles"; -import { useNavigate } from "react-router-dom"; -import { authState, clubSearchState, partSearchState } from "../../atoms/authState"; -import { useRecoilState, useRecoilValue } from "recoil"; -import LeftButton from "../../assets/LeftButton.svg"; -import RightButton from "../../assets/RightButton.svg"; -import axios from "axios"; +import { useLocation, useNavigate } from "react-router-dom"; import axiosInstance from "../../apis/axiosInstance"; import { Container, Header, - Subtitle, - Title, } from "../../components/global.styles"; -import Logo from "../../assets/Logo.svg"; import SearchIcon from "../../assets/Search.svg"; import BackButton from "../../components/button/backButton"; +import MentorCard from "../../components/mentorCard/mentorCard"; +import { MentorData } from "../../types/mentorData"; +import Loading from "../../components/loading/loading"; type MentorCategory = { 기획: "PM"; @@ -31,116 +26,61 @@ const mentorCategory: MentorCategory = { BE: "BE", }; -interface MentorData { - picture: string; - mentorName: string; - clubName: string[]; - field: string; - username: string; -} - -const saveTokenToLocalStorage = (token: string) => { - localStorage.setItem("token", token); -}; - -const getTokenFromLocalStorage = () => { - return localStorage.getItem("token"); -}; - -const saveCategoryToLocalStorage = (category: keyof MentorCategory) => { - localStorage.setItem("activeCategory", category); -}; - -const getCategoryFromLocalStorage = () => { - return localStorage.getItem("activeCategory") as keyof MentorCategory; -}; - export default function SearchView() { - const [activeButtons, setActiveButtons] = useState( - getCategoryFromLocalStorage() || "기획" - ); - const [mentorData, setMentorData] = useState(null); + const [mentorData, setMentorData] = useState([]); + const [selectedMentorData, setSelectedMentorData] = + useState(null); const navigate = useNavigate(); - const [currentIndex, setCurrentIndex] = useState(0); - const part = useRecoilValue(partSearchState); - const club = useRecoilValue(clubSearchState); - - const getMentorData = () => { - const url = `/mentors/part?part=${mentorCategory[activeButtons]}`; - - axiosInstance - .get(url) - .then((response) => { - setMentorData(response.data); - console.log(response.data); - console.log("토큰 아직 유효해용~"); - }) - .catch(async (error) => { - console.error("Error:", error); - // error.response가 undefined가 아닌지 확인 - if (error.response) { - console.log("리이슈 요청 보내야 해용~"); - const originalRequest = error.config; - - // 액세스 토큰 만료 확인 - if (error.response.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - try { - // 리이슈 요청 - const response = await axios.post( - "https://cogo.life/reissue", - {}, - { - headers: { - "Content-Type": "application/json", - }, - withCredentials: true, - } - ); - - // 새로운 액세스 토큰 저장 - const newToken = response.headers.access; - saveTokenToLocalStorage(newToken); - if (newToken) { - console.log("리이슈 성공이용~", newToken); - } - - // 원래 요청의 헤더 업데이트 - originalRequest.headers["Authorization"] = `Bearer ${newToken}`; - - // 원래 요청 재시도 - return axiosInstance(originalRequest); - } catch (reissueError) { - // 리이슈 요청 실패 시 - console.error("Failed to reissue access token:", reissueError); - return Promise.reject(reissueError); - } - - // if (error.response && error.response.status === 401) { - // localStorage.removeItem("isLoggedIn"); - // navigate("/login"); - } else { - console.log("An unexpected error occurred"); - } - } else { - // error.response가 없는 경우 - console.log("Network or unexpected error occurred"); - } - }); - }; + // const [partState, setPartState] = useRecoilValue(partSearchState); + // const [clubState, setClubState] = useRecoilValue(clubSearchState); + const location = useLocation(); + const params = new URLSearchParams(location.search); + const part = params.get("part") || ""; + const club = params.get("club") || ""; + const [isLoading, setIsLoading] = useState(false); useEffect(() => { - getMentorData(); - saveCategoryToLocalStorage(activeButtons); - }, [activeButtons]); + // setPartState(part); + // setClubState(club); + + const fetchMentors = async () => { + setIsLoading(true); + try { + let endpoint = "/mentors"; // 기본 엔드포인트 + const params: Record = {}; + + if (part && club) { + endpoint = "/mentors/part/club"; + params.part = part; + params.club = club; + } else if (part) { + endpoint = "/mentors/part"; + params.part = part; + } else if (club) { + endpoint = "/mentors/club"; + params.club = club; + } - const handleButtonClick = (buttonName: keyof MentorCategory) => { - setActiveButtons(buttonName); + const response = await axiosInstance.get(endpoint, { params }); + setMentorData(response.data.content); + } catch (error) { + console.error("Failed to fetch mentor data:", error); + } finally { + setIsLoading(false); + } + }; + + fetchMentors(); + }, [part, club]); + + const handleProfileSelect = (mentor: MentorData) => { + setSelectedMentorData(mentor); + console.log("selectedData: ", selectedMentorData); + navigate(`/mentor-detail/${mentor.mentorId}`); }; - const handleProfileClick = () => { - navigate("/mentorprofile"); + const handleSearchBarClick = () => { + navigate(`/search`); }; return ( @@ -164,37 +104,21 @@ export default function SearchView() { - - {Array.from({ length: 3 }).map((_, index) => ( - - GDSC - 나는 교휘 - - - 아픈건 딱 질색이니까 - - 오늘도 아침엔 입에 빵을 물고 똑같이 하루를 시작하고 온종일 - 한 손엔 아이스 아메리카노 피곤해 죽겠네 - - - - - - - - {activeButtons} - 직무직무 - 경력 - - - ))} - + {isLoading ? ( + + + + ) : mentorData.length > 0 ? ( + mentorData.map((mentor) => ( + handleProfileSelect(mentor)} + /> + )) + ) : ( +

검색 결과가 없습니다.

+ )}
); diff --git a/src/pages/signup/signup.styles.tsx b/src/pages/signup/signup.styles.tsx index e669455..33f425b 100644 --- a/src/pages/signup/signup.styles.tsx +++ b/src/pages/signup/signup.styles.tsx @@ -65,6 +65,12 @@ export const Input = styled.input` } `; +export const DisplayText = styled.div` + font-size: 2.2rem; + max-width: 24rem; + border-radius: 0; +`; + export const NameInput = styled.input` padding: 1rem 0; width: 100%; @@ -94,13 +100,18 @@ export const CertTime = styled.span` `; export const Button = styled.button` + position: absolute; + z-index: 10; + bottom: 12rem; + left: 50%; + transform: translate(-50%); padding: 1.6rem; background-color: #ededed; color: black; font-size: 2.2rem; font-weight: 500; - width: 50%; - margin: 3rem auto; + width: 43%; + margin: 0 auto; `; export const CheckboxContainer = styled.div` @@ -160,3 +171,10 @@ export const FireImg = styled.img` height: auto; margin: 8rem auto; `; + +export const ErrorText = styled.div` + width: 100%; + color: red; + font-size: 1.5rem; + margin-top: -1rem; +`; \ No newline at end of file diff --git a/src/pages/signup/signup.tsx b/src/pages/signup/signup.tsx index 3944f5a..8178062 100644 --- a/src/pages/signup/signup.tsx +++ b/src/pages/signup/signup.tsx @@ -75,14 +75,15 @@ export default function SignUp() { /> ); case "part": + const nextStepAfterPart = userOption === "멘토" ? "club" : "complete"; return ( ); diff --git a/src/pages/signup/signup_complete.tsx b/src/pages/signup/signup_complete.tsx index fad7d64..cfc08a4 100644 --- a/src/pages/signup/signup_complete.tsx +++ b/src/pages/signup/signup_complete.tsx @@ -10,19 +10,47 @@ import * as S from "./signup.styles"; import { useNavigate } from "react-router-dom"; import StartCogoFire from "../../assets/StartCogoFire.svg"; import { FullButton } from "../../components/global.styles"; +import axiosInstance from "../../apis/axiosInstance"; export default function CompleteStep() { const name = useRecoilValue(nameState); - const phonenum = useRecoilValue(phoneNumberState); + // const phonenum = useRecoilValue(phoneNumberState); const userOption = useRecoilValue(userTypeState); const part = useRecoilValue(partState); const club = useRecoilValue(clubState); const navigate = useNavigate(); const handleClear = () => { - navigate("/"); + completeSignup(part, club); + localStorage.setItem("isLoggedIn", "false"); + localStorage.setItem("token", ""); + navigate("/login"); }; + const completeSignup = async (part: string, club: string) => { + const mentorData = { + part: part, + club: club, + }; + const menteeData = { + part: part, + }; + + try { + if (userOption === "멘토") { + const response = await axiosInstance.post("/users/mentor", mentorData); + console.log(response.data); + } else if (userOption === "멘티") { + const response = await axiosInstance.post("/users/mentee", menteeData); + console.log(response.data); + } + alert("회원가입이 완료되었습니다."); + } catch (error) { + console.error("회원가입 실패: ", error); + alert("회원가입에 실패하셨습니다. 다시 처음부터 회원가입해주세요."); + // navigate("/login"); + } + }; return ( <> diff --git a/src/pages/signup/signup_name.tsx b/src/pages/signup/signup_name.tsx index b95fba2..f3a95b6 100644 --- a/src/pages/signup/signup_name.tsx +++ b/src/pages/signup/signup_name.tsx @@ -1,6 +1,7 @@ -import { nameState } from "../../atoms/authState"; +import { nameState, phoneNumberState } from "../../atoms/authState"; import { useRecoilState } from "recoil"; import * as S from "./signup.styles"; +import axiosInstance from "../../apis/axiosInstance"; interface NameStepProps { goToStep: (step: string) => void; @@ -10,16 +11,29 @@ interface NameStepProps { export default function NameStep({ goToStep }: NameStepProps) { const [name, setName] = useRecoilState(nameState); + const [phoneNumber, setPhoneNumber] = useRecoilState(phoneNumberState); const handleInputChange = (e: React.ChangeEvent) => { setName(e.target.value); }; const handleClear = () => { - if (name === "") { - alert("성함을 작성해 주세요."); - } else { - goToStep("usertype"); + sendUserData(name, phoneNumber); + goToStep("usertype"); + }; + + const sendUserData = async (name: string, phoneNumber: string) => { + const userData = { + phoneNum: phoneNumber, + name: name, + }; + + try { + const response = await axiosInstance.post("/users", userData); + console.log(response.data.content); + } catch (error) { + console.error("전화번호 인증 실패: ", error); + alert("전화번호를 다시 입력해주세요."); } }; diff --git a/src/pages/signup/signup_phoneNum.tsx b/src/pages/signup/signup_phoneNum.tsx index 458f67a..ee450ae 100644 --- a/src/pages/signup/signup_phoneNum.tsx +++ b/src/pages/signup/signup_phoneNum.tsx @@ -13,8 +13,8 @@ interface PhoneNumberStepProps { export default function PhoneNumStep({ goToStep }: PhoneNumberStepProps) { const [phoneNumber, setPhoneNumber] = useRecoilState(phoneNumberState); const [verificationCode, setVerificationCode] = useState(""); - const [isVerificationSent, setIsVerificationSent] = useState(false); const [code, setCode] = useState(""); + const [isVerificationSent, setIsVerificationSent] = useState(false); const [isCodeVerified, setIsCodeVerified] = useState(false); const [timer, setTimer] = useState(180); @@ -25,11 +25,11 @@ export default function PhoneNumStep({ goToStep }: PhoneNumberStepProps) { } }; - const handleVerificationCodeChange = ( + const handleCodeChange = ( e: React.ChangeEvent ) => { if (e.target.value.length <= 4) { - setVerificationCode(e.target.value); + setCode(e.target.value); } }; @@ -49,7 +49,6 @@ export default function PhoneNumStep({ goToStep }: PhoneNumberStepProps) { // 인증번호 전송 함수 const sendVerification = async (phoneNumber: string) => { if (phoneNumber.length < 10) return; - setIsVerificationSent(true); console.log(phoneNumber); try { @@ -59,9 +58,12 @@ export default function PhoneNumStep({ goToStep }: PhoneNumberStepProps) { }, }); if (response.status === 200) { - console.log(response.data); + console.log(response.data.content); + setVerificationCode(response.data.content.verificationCode); + setIsVerificationSent(true); } } catch (error) { + setIsVerificationSent(false); console.error("전화번호 인증 실패: ", error); alert("전화번호를 다시 입력해주세요."); } @@ -70,14 +72,18 @@ export default function PhoneNumStep({ goToStep }: PhoneNumberStepProps) { // 인증번호 재전송 함수 const reSendVerification = () => { setTimer(180); - setVerificationCode(""); + setCode(""); sendVerification(phoneNumber); }; // 인증번호 확인 함수 const verifyCode = () => { - // 인증번호 확인하는 로직 - setIsCodeVerified(true); + if (code === verificationCode) { + setIsCodeVerified(true); + } else { + setIsCodeVerified(false); + alert("인증번호가 일치하지 않습니다."); + } }; //타이머 함수 @@ -146,21 +152,23 @@ export default function PhoneNumStep({ goToStep }: PhoneNumberStepProps) { 인증번호
- {formatTime()} + {!isCodeVerified && ( + {formatTime()} + )} 확인 diff --git a/src/types/cogoData.ts b/src/types/cogoData.ts new file mode 100644 index 0000000..d17f82f --- /dev/null +++ b/src/types/cogoData.ts @@ -0,0 +1,11 @@ +export interface CogoData { + application_id: number; + mentor_id: number; + mentee_id: number; + mentor_name: string; + mentee_name: string; + application_memo: string; + application_date: string; + application_start_time: string; + application_end_time: string; +} diff --git a/src/types/mentorData.ts b/src/types/mentorData.ts new file mode 100644 index 0000000..d24690c --- /dev/null +++ b/src/types/mentorData.ts @@ -0,0 +1,10 @@ +export interface MentorData { + mentorId: number; + picture: string; + mentorName: string; + club: string; + part: string; + username: string; + title: string; + description: string; +} diff --git a/src/types/mentorDetail.ts b/src/types/mentorDetail.ts new file mode 100644 index 0000000..21a7798 --- /dev/null +++ b/src/types/mentorDetail.ts @@ -0,0 +1,12 @@ +export interface MentorDetail { + mentorId: number, + mentorName: string; + part: string; + club: string; + username: string; + introductionTitle: string; + introductionDescription: string; + introductionAnswer1: string; + introductionAnswer2: string; + imageUrl: string; +} \ No newline at end of file diff --git a/src/types/userData.ts b/src/types/userData.ts new file mode 100644 index 0000000..140d50b --- /dev/null +++ b/src/types/userData.ts @@ -0,0 +1,10 @@ +export interface UserData { + name: string; + email: string; + phoneNum: string; + role: string; + part: string; + club: string; + image: string; + picture: string; +} diff --git a/yarn.lock b/yarn.lock index 281920d..fb64163 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6533,6 +6533,18 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lottie-react@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/lottie-react/-/lottie-react-2.4.0.tgz#f7249eee2b1deee70457a2d142194fdf2456e4bd" + integrity sha512-pDJGj+AQlnlyHvOHFK7vLdsDcvbuqvwPZdMlJ360wrzGFurXeKPr8SiRCjLf3LrNYKANQtSsh5dz9UYQHuqx4w== + dependencies: + lottie-web "^5.10.2" + +lottie-web@^5.10.2: + version "5.12.2" + resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.12.2.tgz#579ca9fe6d3fd9e352571edd3c0be162492f68e5" + integrity sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg== + lower-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz"