diff --git a/index.html b/index.html index f1b5528..6a448a2 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@
+ diff --git a/public/icon/close.svg b/public/icon/close.svg index 5f7e135..b3751f1 100644 --- a/public/icon/close.svg +++ b/public/icon/close.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/public/icon/error.svg b/public/icon/error.svg new file mode 100644 index 0000000..a8bf779 --- /dev/null +++ b/public/icon/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icon/kakaotalk.svg b/public/icon/kakaotalk.svg new file mode 100644 index 0000000..ddfa60a --- /dev/null +++ b/public/icon/kakaotalk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/icon/twitter.svg b/public/icon/twitter.svg new file mode 100644 index 0000000..c4081ad --- /dev/null +++ b/public/icon/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index b2de307..0b07641 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,42 +20,43 @@ import MbtiTestQuestions from "@/pages/MbtiTestQuestions"; import MbtiTestResult from "@/pages/MbtiTestResult"; import CenteredLayout from "@/components/CenteredLayout"; import { initGA, trackPageView } from "@/libs/analytics"; +import Error from "@/pages/Error"; const PageTracker = () => { const location = useLocation(); const { pathname, state } = location; const trackedPaths = [ - { path: "/", page: "홈", element: "" }, - { path: "/login", page: "로그인/회원가입", element: "로그인" }, - { path: "/contents", page: "일반콘텐츠", element: "" }, - { path: "/my-info", page: "내 정보", element: "" }, - { path: "/chat", page: "채팅방", element: "" }, - { path: "/select-info", page: "빠른 대화 설정", element: "" }, - { path: "/select-info", page: "친구 저장", element: "대화 시작하기" } + { path: "/", page: "홈" }, + { path: "/login", page: "로그인/회원가입" }, + { path: "/contents", page: "일반콘텐츠" }, + { path: "/my-info", page: "내 정보" }, + { path: "/chat", page: "채팅방" }, + { path: "/select-info", page: "빠른 대화 설정" }, + { path: "/select-info", page: "친구 저장" } ]; useEffect(() => { const trackedContentPaths = ["/contents/1", "/contents/2"]; - trackedPaths.forEach(({ path, page, element }) => { + trackedPaths.forEach(({ path, page }) => { // 콘텐츠 상세 페이지 (일반 콘텐츠만 추적) if (trackedContentPaths.includes(pathname)) { if (path === "/contents") { - trackPageView(path, { page, element }); + trackPageView(path, page); } } // select-info 페이지에서 state로 분기 else if (pathname === "/select-info" && path === pathname) { if (state === "fastFriend" && page === "빠른 대화 설정") { - trackPageView(path, { page, element }); + trackPageView(path, page); } else if (state === "virtualFriend" && page === "친구 저장") { - trackPageView(path, { page, element }); + trackPageView(path, page); } } // 나머지 일반 path else if (pathname === path && path !== "/select-info") { - trackPageView(path, { page, element }); + trackPageView(path, page); } }); }, [location.pathname, location.state]); @@ -77,16 +78,23 @@ const App = () => { } /> } /> } /> - } /> - } /> - } /> + } + /> + } /> + } + /> } /> } /> } /> } /> - } /> + } /> } /> } /> + } /> diff --git a/src/api/axios.ts b/src/api/axios.ts index 8d93c97..8e6c154 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import useAuthStore from "@/store/useAuthStore"; const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, @@ -8,4 +9,33 @@ const instance = axios.create({ } }); +// 인증 절차가 필요한 API는 authInstance로 HTTP요청 +const authInstance = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 10000, + headers: { + "Content-Type": "application/json" + } +}); + +// authInstance 헤더에 accessToken 추가하는 로직 +authInstance.interceptors.request.use( + (config) => { + if (!config.headers) { + config.headers = {}; + } + + const { accessToken } = useAuthStore.getState(); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + export default instance; +export { authInstance }; diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..9f4b63f --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,6 @@ +import { authInstance } from "@/api/axios"; + +export const deleteUser = async () => { + const response = await authInstance.delete("/api/users"); + return response.data; +}; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 3e89aeb..05d556a 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; import { cls } from "@/utils/cls"; import Indicator from "@/components/Indicator"; +import { trackEvent } from "@/libs/analytics"; interface BannerImage { sm: string; @@ -28,12 +29,14 @@ const bannerImages: BannerImage[] = [ md: "/image/home_banner3_md.png", lg: "/image/home_banner3_lg.png", description: "MBTI별 피해야 할 대화스타일 및 주제" - }, + } ]; const Banner = () => { const [order, setOrder] = useState(0); + const bannerEventElements = ["배너 1", "배너 2", "배너 3"]; + useEffect(() => { const interval = setInterval(() => { setOrder((prev) => (prev + 1) % bannerImages.length); @@ -43,18 +46,33 @@ const Banner = () => { return (
- + { + if (bannerEventElements[order]) { + trackEvent("Click", { + page: "홈", + element: bannerEventElements[order] + }); + } + }} + > {bannerImages.map((image, index) => ( - {image.description} + {image.description} ))} diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 2e02757..0dd39c6 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -1,31 +1,68 @@ -const Profile = () => { - return ( -
- Delete +import { SetStateAction } from "react"; +import { useNavigate } from "react-router-dom"; +import { authInstance } from "@/api/axios"; +import { VirtualFriend } from "@/types/virtualFreind"; + +interface ProfileProps { + info: VirtualFriend; + deleteIndex: number; + setVirtualFriendList: React.Dispatch>; +} +const Profile = ({ info, deleteIndex, setVirtualFriendList }: ProfileProps) => { + const navigate = useNavigate(); + + const handleDelete = async () => { + const res = await authInstance.delete( + `/api/virtual-friend/${info.virtualFriendId}` + ); + if (res.status === 200) { + setVirtualFriendList((prevList) => + prevList.filter((_, index) => index !== deleteIndex) + ); + } + }; + const handleNavigate = () => { + navigate("/chat", { + state: { + mode: "virtualFriend", + mbti: info.mbti, + id: info.virtualFriendId + } + }); + }; + + return ( +
+ Profile -
-

- 김엠비 - ENTP +
+

+ {info.virtualFriendName} + {info.mbti}

-

- 20대 · 여자 · 직장동료 · 여행 · 사회생활 +

+ {info.virtualFriendAge} · {info.virtualFriendSex} ·{" "} + {info.virtualFriendRelationship}

- -

diff --git a/src/components/ProfileContainer.tsx b/src/components/ProfileContainer.tsx new file mode 100644 index 0000000..dd98c7d --- /dev/null +++ b/src/components/ProfileContainer.tsx @@ -0,0 +1,27 @@ +import Profile from "@/components/Profile"; +import { VirtualFriend } from "@/types/virtualFreind"; +import { SetStateAction } from "react"; + +interface ProfileContainerProps { + list: VirtualFriend[]; + setVirtualFriendList: React.Dispatch>; +} +const ProfileContainer = ({ + list, + setVirtualFriendList +}: ProfileContainerProps) => { + return ( +
+ {list.map((el, index) => ( + + ))} +
+ ); +}; + +export default ProfileContainer; diff --git a/src/components/StrokeBanner.tsx b/src/components/StrokeBanner.tsx index 092041f..dfd6983 100644 --- a/src/components/StrokeBanner.tsx +++ b/src/components/StrokeBanner.tsx @@ -5,7 +5,7 @@ const StrokeBanner = () => { const handleNavigate = () => { const mode = "virtualFriend"; - navigate("/select-info", { state: mode }); + navigate("/select-info", { state: { type: mode } }); }; return ( diff --git a/src/components/SubTitle.tsx b/src/components/SubTitle.tsx index d1343f9..bb165a4 100644 --- a/src/components/SubTitle.tsx +++ b/src/components/SubTitle.tsx @@ -15,8 +15,8 @@ const SubTitle = ({ mode }: { mode: "빠른대화" | "친구목록" }) => { }; const handleNavigate = () => { - const mode = "virtualFriend"; - navigate("/select-info", { state: mode }); + const type = mode === "빠른대화" ? "fastFriend" : "virtualFriend"; + navigate("/select-info", { state: { type: type } }); }; return ( diff --git a/src/components/button/ChatStartButton.tsx b/src/components/button/ChatStartButton.tsx index 012f41e..556c660 100644 --- a/src/components/button/ChatStartButton.tsx +++ b/src/components/button/ChatStartButton.tsx @@ -1,20 +1,38 @@ import { useNavigate } from "react-router-dom"; +import { trackEvent } from "@/libs/analytics"; -const ChatStartButton = ({mode} : {mode: "go-fast" | "go-virtual" | "go-chat"}) => { +type ChatStartButtonProps = { + mode: "go-fast" | "go-virtual" | "go-chat"; + mbti?: string; +}; + +const ChatStartButton = ({ mode, mbti }: ChatStartButtonProps) => { const navigate = useNavigate(); const handleNavigate = () => { - switch(mode) { - case "go-fast" : navigate("/select-info", {state:"fastFriend"}); - break; - case "go-virtual" : navigate("/select-info", {state:"virtualFriend"}); - break; - case "go-chat" : navigate("/chat"); - break; - default : console.error("mode is invalid", mode); - return; + switch (mode) { + case "go-fast": + trackEvent("Click", { + page: "홈", + element: "빠른 대화 시작" + }); + navigate("/select-info", { state: { type: "fastFriend", mbti } }); + break; + case "go-virtual": + trackEvent("Click", { + page: "홈", + element: "친구 - 바로 대화하기" + }); + navigate("/select-info", { state: { type: "virtualFriend", mbti } }); + break; + case "go-chat": + navigate("/chat"); + break; + default: + console.error("mode is invalid", mode); + return; } - } + }; return ( diff --git a/src/components/button/KakaoShareButton.tsx b/src/components/button/KakaoShareButton.tsx new file mode 100644 index 0000000..1422ab3 --- /dev/null +++ b/src/components/button/KakaoShareButton.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; + +interface KakoShareButtonProps { + title: string; + description: string; + imageUrl: string; +} + +const KakaoShareButton = ({ + title, + description, + imageUrl +}: KakoShareButtonProps) => { + const kakaoJavascriptKey = import.meta.env.VITE_KAKAO_JAVASCRIPT_KEY; + + useEffect(() => { + // 카카오톡 SDK 초기화 + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(kakaoJavascriptKey); + } + }, []); + + const handleShare = () => { + if (window.Kakao) { + // 카카오톡 공유 기능 호출 + window.Kakao.Share.sendDefault({ + objectType: "feed", + content: { + title: title, + description: description, + imageUrl: imageUrl, + link: { + mobileWebUrl: window.location.href, + webUrl: window.location.href + } + } + }); + } + }; + return ( +
+ +
+ ); +}; + +export default KakaoShareButton; diff --git a/src/components/button/ShareButton.tsx b/src/components/button/ShareButton.tsx index 04f1998..e461aa3 100644 --- a/src/components/button/ShareButton.tsx +++ b/src/components/button/ShareButton.tsx @@ -1,9 +1,19 @@ +import { useState } from "react"; +import ShareModal from "@/components/modal/ShareModal"; + const ShareButton = () => { - return ( - - ) -} + {shareModalIsOpen && ( + setShareModalIsOpen(false)} /> + )} + + ); +}; -export default ShareButton; \ No newline at end of file +export default ShareButton; diff --git a/src/components/button/TwitterShareButton.tsx b/src/components/button/TwitterShareButton.tsx new file mode 100644 index 0000000..432f2f0 --- /dev/null +++ b/src/components/button/TwitterShareButton.tsx @@ -0,0 +1,15 @@ +const TwitterShareButton = ({ title }: { title: string }) => { + const currentUrl = window.location.href; + + return ( + + 트위터 아이콘 +

트위터

+
+ ); +}; + +export default TwitterShareButton; diff --git a/src/components/button/UrlCopyBar.tsx b/src/components/button/UrlCopyBar.tsx new file mode 100644 index 0000000..f52429d --- /dev/null +++ b/src/components/button/UrlCopyBar.tsx @@ -0,0 +1,14 @@ +import UrlCopyButton from "@/components/button/UrlCopyButton"; + +const UrlCopyBar = () => { + const currentUrl = window.location.href; + + return ( +
+ {currentUrl} + +
+ ); +}; + +export default UrlCopyBar; diff --git a/src/components/button/UrlCopyButton.tsx b/src/components/button/UrlCopyButton.tsx new file mode 100644 index 0000000..f174e38 --- /dev/null +++ b/src/components/button/UrlCopyButton.tsx @@ -0,0 +1,23 @@ +const UrlCopyButton = ({currentUrl} : {currentUrl : string}) => { + const handleCopy = () => { + navigator.clipboard + .writeText(currentUrl) + .then(() => { + alert("URL이 복사되었습니다!"); // toast로 바꾸어야 함 -> 4.10 정준영 + }) + .catch((err) => { + console.error("URL 복사 실패:", err); + }); + }; + + return ( + + ); + }; + + export default UrlCopyButton; \ No newline at end of file diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index d245cc3..26c198c 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -14,7 +14,7 @@ type HeaderProps = { const Header = ({ title = "", showPreviousIcon = true, - showShareIcon = false, + showShareIcon = true, children }: HeaderProps) => { const { pathname } = useLocation(); diff --git a/src/components/header/SubHeader.tsx b/src/components/header/SubHeader.tsx index b75239e..325bc49 100644 --- a/src/components/header/SubHeader.tsx +++ b/src/components/header/SubHeader.tsx @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { useState } from "react"; import useMbtiTestState from "@/store/useMbtiTestState"; import ActionConfirmModal from "@/components/modal/ActionConfirmModal"; +import ShareModal from "@/components/modal/ShareModal"; type SubHeaderProps = { title: string; @@ -12,17 +13,18 @@ type SubHeaderProps = { const SubHeader = ({ title = "", showPreviousIcon = true, - showShareIcon = false + showShareIcon = true }: SubHeaderProps) => { const navigate = useNavigate(); const { pathname, state } = useLocation(); const { currentPage, setPreviousStep } = useMbtiTestState(); const [isLeaveChatModalOpen, setIsLeaveChatModalOpen] = useState(false); - + const [shareModalIsOpen, setShareModalIsOpen] = useState(false); const isProgressPage = pathname === "/mbti-test-progress"; const isChatPage = pathname === "/chat"; const isFirstQuestionPage = currentPage === 1; const mode = state?.mode; + const chatId = state?.id; const handleGoBack = () => { if (isProgressPage && !isFirstQuestionPage) { @@ -44,36 +46,44 @@ const SubHeader = ({ const handleCancel = () => setIsLeaveChatModalOpen(false); const handleConfirm = () => { + if (chatId) { + sessionStorage.removeItem(`chatMessages_${chatId}`); + } + setIsLeaveChatModalOpen(false); navigate("/"); }; return ( <> -
+
{showPreviousIcon && ( Go To Back )} -

+

{title}

{showShareIcon && ( - Share + )}
@@ -87,6 +97,14 @@ const SubHeader = ({ onConfirm={handleConfirm} /> )} + + {shareModalIsOpen && ( + { + setShareModalIsOpen(false); + }} + /> + )} ); }; diff --git a/src/components/modal/ShareModal.tsx b/src/components/modal/ShareModal.tsx new file mode 100644 index 0000000..ee96d1f --- /dev/null +++ b/src/components/modal/ShareModal.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import UrlCopyBar from "@/components/button/UrlCopyBar"; +import TwitterShareButton from "@/components/button/TwitterShareButton"; +import KakaoShareButton from "@/components/button/KakaoShareButton"; + +interface ShareModalProps { + closeModal: () => void; +} + +const ShareModal = ({ closeModal }: ShareModalProps) => { + const [metaData, setMetaData] = useState({ + title: "", + description: "", + imageUrl: "" + }); + + useEffect(() => { + // 메타 데이터를 가져오는 로직 + const title = + document + .querySelector("meta[property='og:title']") + ?.getAttribute("content") || document.title; + const description = + document + .querySelector("meta[property='og:description']") + ?.getAttribute("content") || ""; + const imageUrl = + document + .querySelector("meta[property='og:image']") + ?.getAttribute("content") || ""; + + setMetaData({ title, description, imageUrl }); + }, []); + + return ( +
+
+

+ 게시글 공유 +

+
+ + +
+ +
+ +
+
+
+ ); +}; + +export default ShareModal; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..ec76eb5 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,4 @@ +// TypeScript 사용 시에는 TypeScript 가 window객체에 존재하는 Kakao객체를 인식할 수 있도록 src내에 global.d.ts를 설정해줘야 오류가 발생하지 않는다. +interface Window { + Kakao: any; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index d1e36de..6b64713 100644 --- a/src/index.css +++ b/src/index.css @@ -63,10 +63,6 @@ button { cursor: pointer; } -button:hover { - opacity: 80%; -} - @keyframes pulse-custom { 0%, 100% { diff --git a/src/libs/analytics.ts b/src/libs/analytics.ts index 667c669..a89920a 100644 --- a/src/libs/analytics.ts +++ b/src/libs/analytics.ts @@ -5,27 +5,22 @@ const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID || ""; const isProduction = import.meta.env.MODE === "production"; export const initGA = () => { - console.log("isProduction", isProduction); - console.log("ID", GA_MEASUREMENT_ID); if (isProduction && GA_MEASUREMENT_ID) { - console.log("initGA"); ReactGA.initialize(GA_MEASUREMENT_ID); } }; -export const trackPageView = (url: string, params?: Record) => { +export const trackPageView = (url: string, page: string) => { if (isProduction && GA_MEASUREMENT_ID) { - console.log("trackPageView"); ReactGA.gtag("event", "page_view", { page_path: url, - ...params + page: page }); } }; export const trackEvent = (name: string, params?: Record) => { if (isProduction && GA_MEASUREMENT_ID) { - console.log("trackEvent"); ReactGA.event(name, params); } }; diff --git a/src/main.tsx b/src/main.tsx index 12fa35b..2b340b4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,5 @@ -import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; -createRoot(document.getElementById("root")!).render( - - - -); +createRoot(document.getElementById("root")!).render(); diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 3e2a67d..ce1f32d 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -6,8 +6,8 @@ import ChatActionBar from "@/components/ChatActionBar"; import pickMbtiImage from "@/utils/pickMbtiImage"; import instance from "@/api/axios"; import { useLocation } from "react-router-dom"; -import { cls } from "@/utils/cls"; import TipsMenuContainer from "@/components/tips/TipsMenuContainer"; +import { trackEvent } from "@/libs/analytics"; interface Message { role: "user" | "assistant"; @@ -20,20 +20,41 @@ interface ChatResponse { const Chat = () => { const { state } = useLocation(); - const { mbti, mode, id } = state; + const { mbti, mode, id = Date.now().toString() } = state; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isOpen, setIsOpen] = useState(false); const bottomRef = useRef(null); + const chatTitle = `${mbti}와 대화`; + const assistantInfo = mbti; + const assistantImgUrl = pickMbtiImage(assistantInfo); + const storageKey = `chatMessages_${id}`; + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isOpen]); - const chatTitle = `${mbti}와 대화`; - const assistantInfo = mbti; - const assistantImgUrl = pickMbtiImage(assistantInfo); + useEffect(() => { + const stored = sessionStorage.getItem(storageKey); + if (stored) setMessages(JSON.parse(stored)); + }, [storageKey]); + + useEffect(() => { + sessionStorage.setItem(storageKey, JSON.stringify(messages)); + }, [messages, storageKey]); + + const handleToggleTips = () => { + const nextAction = !isOpen; + + trackEvent("Click", { + page: "채팅방", + element: nextAction ? "콘텐츠 열기" : "콘텐츠 닫기" + }); + + setIsOpen(nextAction); + }; const handleSend = async (messageToSend: string) => { if (!messageToSend.trim()) return; @@ -88,14 +109,11 @@ const Chat = () => { }; return ( -
+
-
-
- -
- +
+ {/* 메시지 리스트 */} {messages.map((msg, index) => (
{ className="mr-[9px] h-[36px] w-[36px] shrink-0 rounded-full border border-gray-200 object-cover" /> )} - {/* 채팅 메시지 */}
{
+ handleSend(input)} /> + {isOpen && }
); diff --git a/src/pages/Error.tsx b/src/pages/Error.tsx new file mode 100644 index 0000000..b43fc9a --- /dev/null +++ b/src/pages/Error.tsx @@ -0,0 +1,51 @@ +import PrimaryButton from "@/components/button/PrimaryButton"; +import { useNavigate } from "react-router-dom"; + +const Error = ({ statusCode }: { statusCode: "401" | "404" | "500" }) => { + const navigate = useNavigate(); + + const content = { + 401: { + title: "세션이 만료되었습니다.", + description: `활동이 없어 자동으로 로그아웃되었어요.\n채팅을 이어가려면 다시 로그인해 주세요.`, + buttonTitle: "홈으로 가기", + onClick: () => { + navigate("/"); + } + }, + 404: { + title: "페이지를 찾을 수 없습니다.", + description: `입력하신 주소가 올바르지 않거나,\n요청하신 페이지가 삭제되었을 수 있습니다.`, + buttonTitle: "홈으로 가기", + onClick: () => { + navigate("/"); + } + }, + 500: { + title: "앗! 잠시 오류가 일어났어요", + description: `일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해 주세요.`, + buttonTitle: "새로고침 버튼", + onClick: () => { + window.location.reload(); + } + } + }; + return ( +
+
+ 에러 아이콘 +

{content[statusCode].title}

+

+ {content[statusCode].description} +

+
+
+ + {content[statusCode].buttonTitle} + +
+
+ ); +}; + +export default Error; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 5c4165b..d3499a0 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,32 +1,66 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import type { AxiosResponse } from "axios"; +import { authInstance } from "@/api/axios"; +import { VirtualFriend } from "@/types/virtualFreind"; import Banner from "@/components/Banner"; import StrokeBanner from "@/components/StrokeBanner"; import SubTitle from "@/components/SubTitle"; import ChatStartButton from "@/components/button/ChatStartButton"; import Header from "@/components/header/Header"; +import useAuthStore from "@/store/useAuthStore"; +import ProfileContainer from "@/components/ProfileContainer"; const Home = () => { + const navigate = useNavigate(); + const { isLoggedIn } = useAuthStore(); + const [virtualFreindList, setVirtualFriendList] = useState( + [] + ); + + useEffect(() => { + const fetchFriendList = async () => { + try { + const res: AxiosResponse<{ data: VirtualFriend[] }> = + await authInstance.get("/api/virtual-friend"); + setVirtualFriendList(res.data.data); + } catch (err) { + console.error("친구 목록을 불러오지 못했습니다.", err); + navigate("/error"); + } + }; + + if (isLoggedIn) fetchFriendList(); + }, [isLoggedIn]); return ( -
-
+
+
-
+
- +
-
+
- + {isLoggedIn && virtualFreindList.length > 0 ? ( + + ) : ( + + )}
diff --git a/src/pages/KaKaoLogin.tsx b/src/pages/KaKaoLogin.tsx index a23f734..ee1bfa6 100644 --- a/src/pages/KaKaoLogin.tsx +++ b/src/pages/KaKaoLogin.tsx @@ -11,10 +11,10 @@ const KaKaoLogin = () => { const getTokenAndLogin = async () => { if (typeof code === "string") { try { - await login(code); - navigate("/"); + const res = await login(code); + if (res.ok) navigate("/"); } catch (err) { - console.error("카카오 로그인에 실패했습니다."); + navigate("/error"); } } }; diff --git a/src/pages/MbtiTestResult.tsx b/src/pages/MbtiTestResult.tsx index f213ce9..3d2190e 100644 --- a/src/pages/MbtiTestResult.tsx +++ b/src/pages/MbtiTestResult.tsx @@ -7,51 +7,66 @@ import useLayoutSize from "@/hooks/useLayoutSize"; import Header from "@/components/header/Header"; const MbtiTestResult = () => { + const mbti = localStorage.getItem("mbti-test-mbti") ?? ""; + const result = MBTI_RESULT_INFO[mbti as keyof typeof MBTI_RESULT_INFO]; + const size = useLayoutSize(); + const imageURL = + size === "sm" + ? `/image/mbti-test/360px/image_${mbti?.toLocaleLowerCase()}_360.png` + : size === "md" + ? `/image/mbti-test/375px/image_${mbti?.toLocaleLowerCase()}_375.png` + : `/image/mbti-test/500px/image_${mbti?.toLocaleLowerCase()}_500.png`; - const mbti = localStorage.getItem("mbti-test-mbti"); - const result = MBTI_RESULT_INFO[mbti as keyof typeof MBTI_RESULT_INFO]; - const size = useLayoutSize(); - const imageURL = size === "sm" ? `/image/mbti-test/360px/image_${mbti?.toLocaleLowerCase()}_360.png` : (size === "md" ? `/image/mbti-test/375px/image_${mbti?.toLocaleLowerCase()}_375.png` : `/image/mbti-test/500px/image_${mbti?.toLocaleLowerCase()}_500.png`); - console.log(imageURL); - - const handleClick = (e: MouseEvent) => { - e.preventDefault(); - } + if (!result) return
404 error occured
; - if (!result) return
404 error occured
; - - return ( -
-
-
- mbti 테스트 결과 이미지 -

{mbti?.toUpperCase()}는 이런 성향이에요!

-
    -
  • {result.tag[0]}
  • -
  • {result.tag[1]}
  • -
-
-

좋아하는 대화 주제

-
    -
  • {result.topic[0]}
  • -
  • {result.topic[1]}
  • -
-

좋아하는 대화 태도

-
    -
  • {result.attitude[0]}
  • -
  • {result.attitude[1]}
  • -
-
-
- -
-
- - -
-
+ return ( +
+
+
+ mbti 테스트 결과 이미지 +

+ {mbti?.toUpperCase()}는 이런 성향이에요! +

+
    +
  • + {result.tag[0]} +
  • +
  • + {result.tag[1]} +
  • +
+
+

좋아하는 대화 주제

+
    +
  • {result.topic[0]}
  • +
  • {result.topic[1]}
  • +
+

좋아하는 대화 태도

+
    +
  • {result.attitude[0]}
  • +
  • {result.attitude[1]}
  • +
+
+
+ +
+
+ +
- ); -} +
+
+ ); +}; -export default MbtiTestResult; \ No newline at end of file +export default MbtiTestResult; diff --git a/src/pages/MyInfo.tsx b/src/pages/MyInfo.tsx index 52df061..03a2037 100644 --- a/src/pages/MyInfo.tsx +++ b/src/pages/MyInfo.tsx @@ -4,6 +4,8 @@ import ActionConfirmModal from "@/components/modal/ActionConfirmModal"; import useAuthStore from "@/store/useAuthStore"; import { useNavigate } from "react-router-dom"; import TermsAndPrivacyModal from "@/components/modal/TermsAndPrivacyModal"; +import { trackEvent } from "@/libs/analytics"; +import { deleteUser } from "@/api/user"; type ModalType = "logout" | "withdraw" | "terms" | "privacy" | null; @@ -31,12 +33,28 @@ const MyInfo = () => { setModalType(null); }; - const handleConfirm = () => { + const handleConfirm = async () => { if (modalType === "logout") { + trackEvent("Click", { + page: "내 정보", + element: "로그아웃" + }); logout(); navigate("/login"); } else if (modalType === "withdraw") { - console.log("회원탈퇴 실행"); //TODO: 회원탈퇴 기능 구현 시 추가 필요 + trackEvent("Click", { + page: "내 정보", + element: "회원탈퇴" + }); + + try { + await deleteUser(); + logout(); + navigate("/"); + } catch (error) { + console.error(error); + navigate("/error"); + } } setModalType(null); }; diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index f7a1e99..87be9b8 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -1,10 +1,11 @@ -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import FormButton from "@/components/button/FormButton"; import Header from "@/components/header/Header"; import { getMBTIgroup, mapAgeToNumber } from "@/utils/helpers"; -import instance from "@/api/axios"; +import { authInstance } from "@/api/axios"; import ToastMessage from "@/components/ToastMessage"; +import { trackEvent } from "@/libs/analytics"; type FastFriendResponse = { header: { @@ -41,15 +42,20 @@ function isVirtualFriendResponse( const SelectInfo = () => { const navigate = useNavigate(); const location = useLocation(); - const mode = location.state; // mode: fastFriend, virtualFriend 두 종류 존재 - const isNameRequired = mode === "virtualFriend"; + const { type, mbti: testResultMBTI } = location.state; // type: fastFriend, virtualFriend 두 종류 존재 + const isNameRequired = type === "virtualFriend"; const headerTitle = - mode === "fastFriend" ? "상대방 정보선택" : "친구 저장하기"; + type === "fastFriend" ? "상대방 정보선택" : "친구 저장하기"; const selectInfoTitle = - mode === "fastFriend" + type === "fastFriend" ? `상대방의 MBTI를 선택하면\n대화를 시뮬레이션 해볼 수 있어요` : `친구의 MBTI를\n선택해주세요`; + const mbtiTestResult = + typeof location.state === "object" && testResultMBTI !== null + ? testResultMBTI + : undefined; + const [selectedMBTI, setSelectedMBTI] = useState<{ [key: string]: string | null; }>({ @@ -65,6 +71,17 @@ const SelectInfo = () => { const [interest, setInterest] = useState([]); const [toastMessage, setToastMessage] = useState(null); + useEffect(() => { + if (mbtiTestResult && mbtiTestResult.length === 4) { + setSelectedMBTI({ + E: mbtiTestResult[0], + N: mbtiTestResult[1], + F: mbtiTestResult[2], + P: mbtiTestResult[3] + }); + } + }, [mbtiTestResult]); + const mbtiOptions = ["E", "N", "F", "P", "I", "S", "T", "J"]; const ageOptions = ["10대", "20대", "30대 이상"]; const genderOptions = ["여자", "남자"]; @@ -157,7 +174,7 @@ const SelectInfo = () => { }; const selectedData = - mode === "virtualFriend" + type === "virtualFriend" ? { ...commonData, friendName: name, @@ -172,28 +189,36 @@ const SelectInfo = () => { }; const apiUrl = - mode === "virtualFriend" ? "api/virtual-friend" : "api/fast-friend"; + type === "virtualFriend" ? "api/virtual-friend" : "api/fast-friend"; try { - const response = await instance.post( + const response = await authInstance.post( `/${apiUrl}`, selectedData ); const responseData = response.data.data; - if (mode === "virtualFriend" && isVirtualFriendResponse(responseData)) { + if (type === "virtualFriend" && isVirtualFriendResponse(responseData)) { + trackEvent("Click", { + page: "친구 저장", + element: "대화 시작하기" + }); navigate("/chat", { state: { mbti, - mode, + mode: type, id: responseData.conversationId } }); - } else if (mode === "fastFriend" && typeof responseData === "number") { + } else if (type === "fastFriend" && typeof responseData === "number") { + trackEvent("Click", { + page: "빠른 대화 설정", + element: "대화 시작하기" + }); navigate("/chat", { state: { mbti, - mode, + mode: type, id: responseData } }); diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index a8ea6ae..3bd3cca 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -5,7 +5,7 @@ import instance from "@/api/axios"; interface AuthStore { isLoggedIn: boolean; accessToken: string | null; - login: (code: string) => Promise; + login: (code: string) => Promise<{ ok: boolean }>; logout: () => void; } @@ -16,15 +16,22 @@ const useAuthStore = create( accessToken: null, login: async (code: string) => { try { - const res = await instance.get(`/api/kakao/login?code=${code}`); - if (res.data) { - set({ - isLoggedIn: true, - accessToken: res.data as string - }); - } + const requestURI = + // 아래 코드는 백엔드팀에서 작업해주시면 동일한 uri로 바뀔 예정 -> 4.24 정준영 + import.meta.env.MODE === "production" + ? `/api/kakao/login?code=${code}` + : `/api/kakao/login?code=${code}&redirectUrl=https://localhost:5173/kakao-login`; + + const res = await instance.get(requestURI); + set({ + isLoggedIn: true, + accessToken: res.data.data as string + }); + return { + ok: true + }; } catch (error) { - console.error("Error during login:", error); + throw error; } }, logout: () => { @@ -32,6 +39,7 @@ const useAuthStore = create( isLoggedIn: false, accessToken: null }); + return { ok: true }; } }), { diff --git a/src/types/virtualFreind.ts b/src/types/virtualFreind.ts new file mode 100644 index 0000000..3292655 --- /dev/null +++ b/src/types/virtualFreind.ts @@ -0,0 +1,9 @@ +export interface VirtualFriend { + virtualFriendId: number; + conversationId: number; + mbti: string; + virtualFriendName: string; + virtualFriendAge: number; + virtualFriendSex: string; + virtualFriendRelationship: string; +}