diff --git a/src/App.tsx b/src/App.tsx index bdda6ca..9987ecd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,7 +83,7 @@ function Layout({ routes_children }: { routes_children: RouteChildren[] }) {
- +
{routes_children.find((child) => matchPath(child.path, pathname))?.hasBottomBar && ( diff --git a/src/apis/Chat/Socket.ts b/src/apis/Chat/Socket.ts index 833d292..9f076cf 100644 --- a/src/apis/Chat/Socket.ts +++ b/src/apis/Chat/Socket.ts @@ -1,6 +1,8 @@ import { Stomp } from "@stomp/stompjs"; import SockJS from "sockjs-client"; +import { createRequestOptionsJSON_AUTH, fetchApi } from "@/apis/_createRequestOptions"; + export const SocketConnect = ( stompClient: any, chatRoomId: string, @@ -13,6 +15,7 @@ export const SocketConnect = ( if (!token || !stompClient.current.onConnect) { //로그인 오류 처리 //alert("로그인이 필요합니다."); + console.log("!token || !stompClient.current.onConnect, 로그인 오류"); return; } @@ -37,3 +40,14 @@ export const SocketDisconnect = (stompClient: any) => { stompClient.current.disconnect(); } }; + +/** */ +export const ChatroomLeave = async (chatRoomId: number) => { + const requestOptions = createRequestOptionsJSON_AUTH("POST"); + const spaceId = Number(localStorage.getItem("spaceId")); + if (!requestOptions || !spaceId) return null; + + const url = `${import.meta.env.VITE_API_BACK_URL}/space/${spaceId}/chat/${chatRoomId}/leave`; + + return await fetchApi(url, requestOptions); +}; diff --git a/src/apis/index.ts b/src/apis/index.ts index fc4d0e0..bd6e917 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,12 +1,23 @@ export * from "@/apis/LoginApi"; +export * from "@/apis/voiceroomApi"; +export * from "@/apis/GetUserProfileApi"; -export * from "@/apis/Chat/ChatroomSearchAllApi"; -export * from "@/apis/Chat/ChatroomCreateApi"; export * from "@/apis/Chat/ChatType"; -export * from "@/apis/Chat/ChatroomUpdateNameApi"; +export * from "@/apis/Chat/ChatroomCreateApi"; +export * from "@/apis/Chat/ChatroomExitDelete"; +export * from "@/apis/Chat/ChatroomSearchAllApi"; export * from "@/apis/Chat/ChatroomSearchAllUserApi"; +export * from "@/apis/Chat/ChatroomUpdateNameApi"; export * from "@/apis/Chat/Socket"; +export * from "@/apis/Pay/PayPageAPI"; + export * from "@/apis/Space/SpaceCreateApi"; +export * from "@/apis/Space/SpaceJoinInfoApi"; +export * from "@/apis/Space/SpaceSearchAllUserApi"; export * from "@/apis/Space/SpaceSearchUserProfile"; +export * from "@/apis/Space/SpaceSelectApi"; +export * from "@/apis/Space/SpaceUserJoinApi"; + +export * from "@/apis/_createRequestOptions"; diff --git a/src/components/SignUpHeader.tsx b/src/components/SignUpHeader.tsx index 24590e5..c989710 100644 --- a/src/components/SignUpHeader.tsx +++ b/src/components/SignUpHeader.tsx @@ -1,52 +1,52 @@ import React from "react"; import styled from "styled-components"; + import back from "@/assets/icon_back.svg"; -import { useNavigate } from "react-router-dom"; const HeaderContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 3.25rem; - padding: 0.5rem 0; - position: relative; - max-width: 40rem; - margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 3.25rem; + padding: 0.5rem 0; + position: relative; + max-width: 40rem; + margin: 0 auto; `; const Back = styled.button` - position: absolute; - left: 0.63rem; - width: 2.25rem; - height: 2.25rem; - cursor: pointer; + position: absolute; + left: 0.63rem; + width: 2.25rem; + height: 2.25rem; + cursor: pointer; `; const Title = styled.div` - color: #fff; - font-family: Freesentation; - font-size: 1.25rem; - font-style: normal; - font-weight: 500; - line-height: 120%; - letter-spacing: 0.05rem; + color: #fff; + font-family: Freesentation; + font-size: 1.25rem; + font-style: normal; + font-weight: 500; + line-height: 120%; + letter-spacing: 0.05rem; `; interface HeaderProps { - title: string; - onBackClick: () => void; + title: string; + onBackClick: () => void; } const SignUpHeader: React.FC = ({ title, onBackClick }) => { - return ( - - - Back - - {title} - - ); + return ( + + + Back + + {title} + + ); }; export default SignUpHeader; diff --git a/src/pages/ChatPage/ChatPage.styled.ts b/src/pages/ChatPage/ChatPage.styled.ts index 10a81a1..0b0ef77 100644 --- a/src/pages/ChatPage/ChatPage.styled.ts +++ b/src/pages/ChatPage/ChatPage.styled.ts @@ -33,6 +33,7 @@ export const ChatContainer = styled.div` } .chat--container { + position: relative; display: flex; flex-direction: column; flex-grow: 1; @@ -77,6 +78,7 @@ export const ChatContainer = styled.div` .chat-btn-detail { max-width: 13.5rem; + max-height: 4.125rem; color: var(--Foundation-Gray-gray300, var(--GRAY-300, #d4d4d9)); /* text/Regular 14pt */ @@ -85,6 +87,14 @@ export const ChatContainer = styled.div` font-weight: 400; line-height: 140%; /* 19.6px */ letter-spacing: 0.035rem; + + img { + width: auto; + height: 100%; + position: absolute; + right: 4rem; + top: 0; + } } .chat-btn-chatNum { diff --git a/src/pages/ChatPage/ChatPage.tsx b/src/pages/ChatPage/ChatPage.tsx index 9be0fee..c0109bb 100644 --- a/src/pages/ChatPage/ChatPage.tsx +++ b/src/pages/ChatPage/ChatPage.tsx @@ -18,10 +18,13 @@ const ChatPage = () => { // fetch로 data 받아오는 부분 // 임시로 LOCALSTORAGE에 spaceId 3으로 저장 - localStorage.setItem("spaceId", "3"); + // localStorage.setItem("spaceId", "3"); // const spaceId = localStorage.getItem("spaceId"); - if (spaceId !== null) { + if (spaceId === null) { + console.log("spacdId === null"); + navigate("/space"); + } else if (spaceId !== null) { chatroomSearchAllApi(Number.parseInt(spaceId)) .then((res) => { if (res === null) { diff --git a/src/pages/ChatPage/ChattingPage/ChattingPage.tsx b/src/pages/ChatPage/ChattingPage/ChattingPage.tsx index 06985d3..56cf757 100644 --- a/src/pages/ChatPage/ChattingPage/ChattingPage.tsx +++ b/src/pages/ChatPage/ChattingPage/ChattingPage.tsx @@ -8,6 +8,7 @@ import { ChatPay, ChatPost, Chatroom, + ChatroomLeave, ChatSendRequestFrame, ChatText, SocketConnect, @@ -78,8 +79,11 @@ const ChattingPage = () => { }); SocketConnect(stompClient, param.id || "", handleChatMessage); - return () => SocketDisconnect(stompClient); - }, [param.id, spaceId]); + return () => { + SocketDisconnect(stompClient); + ChatroomLeave(chatroomInfo.id).then((res) => console.log(res)); //사용자 채팅방 떠남 알려주기 (unsubscribe..?) + }; + }, [param.id, spaceId, chatroomInfo.id]); // // diff --git a/src/pages/LoginPage/LoginModal.tsx b/src/pages/LoginPage/LoginModal.tsx index c157630..b5c7790 100644 --- a/src/pages/LoginPage/LoginModal.tsx +++ b/src/pages/LoginPage/LoginModal.tsx @@ -3,14 +3,14 @@ import { useLocation, useNavigate } from "react-router-dom"; import StopModal from "@/components/StopModal"; -export const LoginModal = () => { +export const LoginModal = ({ exceptionRouters }: { exceptionRouters: string[] }) => { const navigate = useNavigate(); const location = useLocation(); // 현재 경로를 가져옴 const [isModalOpen, setIsModalOpen] = useState(false); useEffect(() => { - if (location.pathname === "/login") { + if (exceptionRouters.includes(location.pathname)) { return; } diff --git a/src/pages/LoginPage/LoginPage.tsx b/src/pages/LoginPage/LoginPage.tsx index d527f47..109b091 100644 --- a/src/pages/LoginPage/LoginPage.tsx +++ b/src/pages/LoginPage/LoginPage.tsx @@ -41,7 +41,7 @@ const LoginPage = () => { const handleLogin = async () => { if (!isButtonActive) return; loginApi(email, password).then((res) => - res.status === "OK" ? navigate("/") : alert("login error: " + res.message), + res.status === "OK" ? navigate("/space") : alert("login error: " + res.message), ); }; diff --git a/src/pages/LoginPage/SignUpPage.styled.ts b/src/pages/LoginPage/SignUpPage.styled.ts index 5ba2847..4e546e7 100644 --- a/src/pages/LoginPage/SignUpPage.styled.ts +++ b/src/pages/LoginPage/SignUpPage.styled.ts @@ -1,123 +1,136 @@ import styled from "styled-components"; export const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - max-width: 40rem; - margin: 0 auto; - box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + max-width: 40rem; + margin: 0 auto; + box-sizing: border-box; `; export const StyledText = styled.div` - color: #fff; - font-family: Freesentation; - font-size: 1.5rem; - font-style: normal; - font-weight: 600; - line-height: 140%; - letter-spacing: 0.03rem; - width: calc(100% - 2.5rem); - margin-top: 2.5rem; + color: #fff; + font-family: Freesentation; + font-size: 1.5rem; + font-style: normal; + font-weight: 600; + line-height: 140%; + letter-spacing: 0.03rem; + width: calc(100% - 2.5rem); + margin-top: 2.5rem; `; interface ExplanationProps { - $state?: "empty" | "invalid" | "valid"; - $isValid?: boolean; + $state?: "empty" | "invalid" | "valid"; + $isValid?: boolean; } export const Explanation = styled.div` - color: ${({ $state, $isValid }) => { - if ($state === "empty") return "#767681"; - if ($isValid === false) return "#FF5656"; - if ($isValid === true) return "#48FFBD"; - return "#767681"; - }}; - font-family: Freesentation; - font-size: 0.875rem; - font-style: normal; - font-weight: 300; - line-height: 140%; - letter-spacing: 0.0175rem; - margin-top: 0.38rem; - width: calc(100% - 2.5rem); + color: ${({ $state, $isValid }) => { + if ($state === "empty") return "#767681"; + if ($isValid === false) return "#FF5656"; + if ($isValid === true) return "#48FFBD"; + return "#767681"; + }}; + font-family: Freesentation; + font-size: 0.875rem; + font-style: normal; + font-weight: 300; + line-height: 140%; + letter-spacing: 0.0175rem; + margin-top: 0.38rem; + width: calc(100% - 2.5rem); + + span { + color: ${({ $state, $isValid }) => { + if ($state === "empty") return "#767681"; + if ($isValid === false) return "#FF5656"; + if ($isValid === true) return "#48FFBD"; + return "#767681"; + }}; + } `; export const InputContainer = styled.div` - display: flex; - position: relative; - width: calc(100% - 2.5rem); - max-width: 40rem; + display: flex; + position: relative; + width: calc(100% - 2.5rem); + max-width: 40rem; `; -export const Input = styled.input<{ $state?: "empty" | "invalid" | "valid"; $isValid?: boolean; $isOverMaxLength?: boolean }>` - display: flex; - width: 100%; - height: 3.25rem; - border-radius: 0.75rem; - padding: 0.9375rem; - padding-left: 1rem; - border: 1px solid transparent; - background-color: #222226; - font-family: Freesentation; - font-size: 1rem; - font-style: normal; - font-weight: 400; - line-height: 140%; - letter-spacing: 0.04rem; - color: #ffffff; - caret-color: #48ffbd; - margin-top: 3.25rem; - box-sizing: border-box; +export const Input = styled.input<{ + $state?: "empty" | "invalid" | "valid"; + $isValid?: boolean; + $isOverMaxLength?: boolean; +}>` + display: flex; + width: 100%; + height: 3.25rem; + border-radius: 0.75rem; + padding: 0.9375rem; + padding-left: 1rem; + border: 1px solid transparent; + background-color: #222226; + font-family: Freesentation; + font-size: 1rem; + font-style: normal; + font-weight: 400; + line-height: 140%; + letter-spacing: 0.04rem; + color: #ffffff; + caret-color: #48ffbd; + margin-top: 3.25rem; + box-sizing: border-box; - &::placeholder { - color: #767681; - } + &::placeholder { + color: #767681; + } - &:focus { - border-color: ${({ $state, $isValid }) => ($isValid ? "#48FFBD" : $state === "invalid" ? "#FF5656" : "#48FFBD")}; - outline: none; - } + &:focus { + border-color: ${({ $isValid }) => ($isValid ? "#48FFBD" : "#FF5656")}; + outline: none; + } `; interface NextButtonProps { - $isActive: boolean; - $isInputFocused: boolean; + $isActive: boolean; + $isInputFocused: boolean; } export const NextButton = styled.button` - display: flex; - width: calc(100% - 2.5rem); - max-width: 37.5rem; - height: 3.25rem; - position: fixed; - bottom: ${({ $isInputFocused }) => ($isInputFocused ? "0" : "0.75rem")}; - padding: 0.875rem 0 0.8125rem 0; - justify-content: center; - align-items: center; - background-color: ${({ $isActive }) => ($isActive ? "#48FFBD" : "#45454B")}; - color: ${({ $isActive }) => ($isActive ? "#171719" : "#ACACB5")}; - border-radius: 0.75rem; - font-family: Freesentation; - font-size: 1.125rem; - font-style: normal; - font-weight: 700; - line-height: 140%; - letter-spacing: 0.045rem; - cursor: ${({ $isActive }) => ($isActive ? "pointer" : "default")}; + display: flex; + width: calc(100% - 2.5rem); + max-width: 37.5rem; + height: 3.25rem; + position: fixed; + bottom: ${({ $isInputFocused }) => ($isInputFocused ? "0" : "0.75rem")}; + padding: 0.875rem 0 0.8125rem 0; + justify-content: center; + align-items: center; + background-color: ${({ $isActive }) => ($isActive ? "#48FFBD" : "#45454B")}; + color: ${({ $isActive }) => ($isActive ? "#171719" : "#ACACB5")}; + border-radius: 0.75rem; + font-family: Freesentation; + font-size: 1.125rem; + font-style: normal; + font-weight: 700; + line-height: 140%; + letter-spacing: 0.045rem; + cursor: ${({ $isActive }) => ($isActive ? "pointer" : "default")}; `; export const NameCount = styled.span` - position: absolute; - top: 0.94rem; - right: 1rem; - color: #767681; - font-family: Freesentation; - font-size: 1rem; - font-style: normal; - font-weight: 400; - line-height: 140%; - letter-spacing: 0.04rem; - padding: 0 0.25rem; - z-index: 300; + position: absolute; + top: 0.94rem; + right: 1rem; + color: #767681; + font-family: Freesentation; + font-size: 1rem; + font-style: normal; + font-weight: 400; + line-height: 140%; + letter-spacing: 0.04rem; + padding: 0 0.25rem; + z-index: 300; `; diff --git a/src/pages/LoginPage/SignUpPage.tsx b/src/pages/LoginPage/SignUpPage.tsx index 90d60c0..95e5090 100644 --- a/src/pages/LoginPage/SignUpPage.tsx +++ b/src/pages/LoginPage/SignUpPage.tsx @@ -1,17 +1,18 @@ import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; + import SignUpHeader from "@/components/SignUpHeader"; +import StopModal from "@/components/StopModal"; import { - StyledText, Container, - Input, - NextButton, Explanation, - NameCount, + Input, InputContainer, + NameCount, + NextButton, + StyledText, } from "@/pages/LoginPage/SignUpPage.styled"; -import StopModal from "@/components/StopModal"; -import { useNavigate } from "react-router-dom"; -import axios from "axios"; const SignUp: React.FC = () => { const [email, setEmail] = useState(""); @@ -36,7 +37,7 @@ const SignUp: React.FC = () => { const isValidConfirmPassword = confirmPassword === password; if (password.trim() === "") { setPasswordState("empty"); - } else if (!isValidPassword) { + } else if (!isValidPassword || !validatePassword(password)) { setPasswordState("invalid"); } else { setPasswordState("valid"); @@ -57,7 +58,11 @@ const SignUp: React.FC = () => { }, [email, password, confirmPassword, name, currentStep]); const handleBackClick = () => { - setIsModalOpen(true); + if (currentStep > 1) { + setCurrentStep((prev) => prev - 1); + } else { + setIsModalOpen(true); + } }; const handleCloseModal = () => { @@ -66,163 +71,222 @@ const SignUp: React.FC = () => { const handleConfirmModal = () => { setIsModalOpen(false); - navigate(-1); + if (currentStep > 1) { + setCurrentStep((prev) => prev - 1); + } else { + navigate(-1); + } }; - const validateEmail = (email: string) => { + const validateEmail = (email: string) => { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return re.test(email); -}; + }; -const validatePassword = (password: string) => { + const validatePassword = (password: string) => { const re = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,16}$/; return re.test(password); -}; + }; -const validateUserName = (name: string) => { + const validateUserName = (name: string) => { return name.length >= 1 && name.length <= 10; -}; + }; -const handleNextButtonClick = async () => { + const handleNextButtonClick = async () => { + if (currentStep === 1) { + if (!validateEmail(email)) { + alert("Invalid email format.\nproject@space.kuit 형식으로 입력해야 합니다."); + return; + } + } + if (currentStep === 2) { + if (!validatePassword(password)) { + alert("Invalid password format"); + return; + } + } if (currentStep === 3) { - if (!validateEmail(email)) { - console.error("Invalid email format"); - return; - } - - if (!validatePassword(password)) { - console.error("Invalid password format"); - return; - } - - if (!validateUserName(name)) { - console.error("Invalid username length"); - return; - } - - try { - const response = await axios.post('/api/user/signup', { - email: email, - password: password, - userName: name, - }); - - if (response.status === 200) { - console.log("회원가입 성공:", response.data.message); - navigate('/login'); - } else { - console.error("회원가입 실패:", response.data.message); - } - } catch (error) { - if (error instanceof Error) { - console.error("회원가입 실패:", error.message); - } else { - console.error("회원가입 실패:", error); + if (!validateUserName(name)) { + alert("Invalid username length"); + return; + } + + // try { + axios + .post("/api/user/signup", { + email: email, + password: password, + userName: name, + }) + .then((response) => { + if (response.status === 200) { + console.log("회원가입 성공:", response.data.message); + navigate("/login"); + } else { + console.error("회원가입 실패:", response.data.message); + } + }) + .catch((err) => { + if (err.response.status.toString().startsWith("4")) { + if (err.response.data.message === "이미 존재하는 이메일입니다.") { + setCurrentStep(1); + alert("이미 존재하는 이메일입니다."); } - } + } + console.error(err); + }); } else { - setCurrentStep((prevStep) => prevStep + 1); + setCurrentStep((prevStep) => prevStep + 1); } -}; - - const isNameOverMaxLength = name.length > 10; - return ( - <> - - - {currentStep === 1 && ( - <> - - <> - 아이디로 사용될 -
- 이메일을 입력해주세요 - -
- - setEmail(e.target.value)} onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} /> - - - 다음 - - - )} - {currentStep === 2 && ( - <> - 비밀번호를 입력해주세요 - - setPassword(e.target.value)} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - style={{ marginTop: "5.28rem" }} - $state={passwordState} - $isValid={passwordState === "valid"} - /> - - - {passwordState === "empty" && "비밀번호를 입력해주세요.(8~20자)"} - {passwordState === "invalid" && "사용 불가능한 비밀번호입니다."} - {passwordState === "valid" && "사용 가능한 비밀번호입니다."} - - - setConfirmPassword(e.target.value)} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - style={{ marginTop: "0.75rem" }} - $state={confirmPasswordState} - $isValid={confirmPasswordState === "valid"} - /> - - - {confirmPasswordState === "empty" ? "비밀번호를 한 번 더 입력해주세요." : confirmPasswordState === "valid" ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다."} - - - 다음 - - - - )} - {currentStep === 3 && ( - <> - 이름을 입력해주세요 - - setName(e.target.value)} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - style={{ marginTop: "5.5rem", borderColor: isNameOverMaxLength ? "#FF5656" : undefined }} - $isOverMaxLength={isNameOverMaxLength} - /> - {`${name.length}/10`} - - - 시작하기 - - - )} - -
- - ); + }; + + return ( + <> + + + {currentStep === 1 && ( + <> + + <> + 아이디로 사용될 +
+ 이메일을 입력해주세요 + +
+ + setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleNextButtonClick()} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + $isValid={validateEmail(email)} + /> + + + {email.trim() === "" && "이메일을 입력해주세요."} + {email.trim() !== "" && !validateEmail(email) && "사용 불가능한 이메일입니다."} + {validateEmail(email) && "사용 가능한 이메일입니다."} + + + + 다음 + + + )} + {currentStep === 2 && ( + <> + 비밀번호를 입력해주세요 + + setPassword(e.target.value)} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + style={{ marginTop: "5.28rem" }} + $state={passwordState} + $isValid={passwordState === "valid"} + /> + + + {passwordState === "empty" && "비밀번호를 입력해주세요.(8~20자)"} + {passwordState === "invalid" && ( + + 사용 불가능한 비밀번호입니다.
대문자, 소문자, 숫자, 특수문자가 각 1개 이상 + 포함되어야 합니다. +
+ )} + {passwordState === "valid" && "사용 가능한 비밀번호입니다."} +
+ + + setConfirmPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleNextButtonClick()} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + style={{ marginTop: "0.75rem" }} + $state={confirmPasswordState} + $isValid={confirmPasswordState === "valid"} + /> + + + {confirmPasswordState === "empty" + ? "비밀번호를 한 번 더 입력해주세요." + : confirmPasswordState === "valid" + ? "비밀번호가 일치합니다." + : "비밀번호가 일치하지 않습니다."} + + + + 다음 + + + )} + {currentStep === 3 && ( + <> + 이름을 입력해주세요 + + { + if (e.target.value.length <= 10) setName(e.target.value); + }} + onKeyDown={(e) => e.key === "Enter" && handleNextButtonClick()} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + style={{ + marginTop: "5.5rem", + borderColor: validateUserName(name) ? "#48FFBD" : "#FF5656", + }} + $isOverMaxLength={name.length > 10} + /> + {`${name.length}/10`} + + + 시작하기 + + + )} + +
+ + ); }; export default SignUp; diff --git a/src/utils/decodedJWT.ts b/src/utils/decodedJWT.ts index 1356d06..d9c2759 100644 --- a/src/utils/decodedJWT.ts +++ b/src/utils/decodedJWT.ts @@ -1,3 +1,7 @@ +/** JWT가 있으면 복호화해서 userId 찾는 함수 + * decodedJWT().userId 로 사용 가능 + * @returns if null, jwt 없음 + */ export const decodedJWT = (): { exp: number; iat: number; userId: number } | null => { let token = localStorage.getItem("Authorization"); if (!token) return null;