diff --git a/src/App.tsx b/src/App.tsx index 0b07641..01ec187 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,7 +33,11 @@ const PageTracker = () => { { path: "/my-info", page: "내 정보" }, { path: "/chat", page: "채팅방" }, { path: "/select-info", page: "빠른 대화 설정" }, - { path: "/select-info", page: "친구 저장" } + { path: "/mbti-test", page: "바이럴 콘텐츠 소개" }, + { path: "/mbti-result", page: "바이럴 콘텐츠 결과" }, + { path: "/chat-recommend", page: "대화주제추천" }, + { path: "/chat-tips", page: "대화 꿀팁" }, + { path: "/chat-temporature", page: "대화 온도" } ]; useEffect(() => { @@ -67,7 +71,6 @@ const PageTracker = () => { const App = () => { useEffect(() => { initGA(); - console.log("init"); }, []); return ( diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 0dd39c6..19bae99 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -2,6 +2,7 @@ import { SetStateAction } from "react"; import { useNavigate } from "react-router-dom"; import { authInstance } from "@/api/axios"; import { VirtualFriend } from "@/types/virtualFreind"; +import trackClickEvent from "@/utils/trackClickEvent"; interface ProfileProps { info: VirtualFriend; @@ -12,6 +13,7 @@ const Profile = ({ info, deleteIndex, setVirtualFriendList }: ProfileProps) => { const navigate = useNavigate(); const handleDelete = async () => { + trackClickEvent("홈", "친구 - 삭제"); const res = await authInstance.delete( `/api/virtual-friend/${info.virtualFriendId}` ); @@ -23,11 +25,13 @@ const Profile = ({ info, deleteIndex, setVirtualFriendList }: ProfileProps) => { }; const handleNavigate = () => { + trackClickEvent("홈", "친구 - 바로 대화하기"); navigate("/chat", { state: { mode: "virtualFriend", mbti: info.mbti, - id: info.virtualFriendId + id: info.virtualFriendId, + name: info.virtualFriendName } }); }; diff --git a/src/components/SubTitle.tsx b/src/components/SubTitle.tsx index f55ebc1..21400b3 100644 --- a/src/components/SubTitle.tsx +++ b/src/components/SubTitle.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import trackClickEvent from "@/utils/trackClickEvent"; import ActionConfirmModal from "@/components/modal/ActionConfirmModal"; import useAuthStore from "@/store/useAuthStore"; @@ -20,6 +21,7 @@ const SubTitle = ({ mode }: { mode: "빠른대화" | "친구목록" }) => { }; const handleNavigate = () => { + trackClickEvent("홈", "친구 - 추가"); if (isLoggedIn) { const type = mode === "빠른대화" ? "fastFriend" : "virtualFriend"; navigate("/select-info", { state: { type: type } }); diff --git a/src/components/button/ChatStartButton.tsx b/src/components/button/ChatStartButton.tsx index 556c660..a907dd6 100644 --- a/src/components/button/ChatStartButton.tsx +++ b/src/components/button/ChatStartButton.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { trackEvent } from "@/libs/analytics"; type ChatStartButtonProps = { @@ -8,13 +8,17 @@ type ChatStartButtonProps = { const ChatStartButton = ({ mode, mbti }: ChatStartButtonProps) => { const navigate = useNavigate(); + const pathname = useLocation().pathname; const handleNavigate = () => { switch (mode) { case "go-fast": trackEvent("Click", { - page: "홈", - element: "빠른 대화 시작" + page: pathname === "/mbti-test-result" ? "바이럴 콘텐츠 결과" : "홈", + element: + pathname === "/mbti-test-result" + ? "대화 시작하기" + : "빠른 대화 시작" }); navigate("/select-info", { state: { type: "fastFriend", mbti } }); break; diff --git a/src/components/button/KakaoLoginButton.tsx b/src/components/button/KakaoLoginButton.tsx index bb44de2..55760ff 100644 --- a/src/components/button/KakaoLoginButton.tsx +++ b/src/components/button/KakaoLoginButton.tsx @@ -1,3 +1,5 @@ +import trackClickEvent from "@/utils/trackClickEvent"; + const KakaoLoginButton = () => { const kakaoRestApiKey = import.meta.env.VITE_KAKAO_REST_API_KEY; const kakaoRedirectUrl = @@ -8,6 +10,7 @@ const KakaoLoginButton = () => { const kakaoURL = `https://kauth.kakao.com/oauth/authorize?client_id=${kakaoRestApiKey}&redirect_uri=${kakaoRedirectUrl}&response_type=code`; const handleClick = () => { + trackClickEvent("로그인/회원가입", "로그인"); window.location.href = kakaoURL; }; diff --git a/src/components/button/LoginButton.tsx b/src/components/button/LoginButton.tsx index 40e5162..2118365 100644 --- a/src/components/button/LoginButton.tsx +++ b/src/components/button/LoginButton.tsx @@ -1,8 +1,14 @@ +import trackClickEvent from "@/utils/trackClickEvent"; + const LoginButton = () => { + const handleClick = () => { + trackClickEvent("홈", "로그인"); + }; + return ( - - ); diff --git a/src/components/button/RestartButton.tsx b/src/components/button/RestartButton.tsx index 7f00e3d..0f6dae5 100644 --- a/src/components/button/RestartButton.tsx +++ b/src/components/button/RestartButton.tsx @@ -1,17 +1,20 @@ import { useNavigate } from "react-router-dom"; const RestartButton = () => { - const navigate = useNavigate(); + const navigate = useNavigate(); - const goFirstStep = () => { - navigate("/mbti-test"); - } + const goFirstStep = () => { + navigate("/mbti-test"); + }; - return ( - - ) -} + return ( + + ); +}; -export default RestartButton; \ No newline at end of file +export default RestartButton; diff --git a/src/components/button/ShareButton.tsx b/src/components/button/ShareButton.tsx index e461aa3..8d31be6 100644 --- a/src/components/button/ShareButton.tsx +++ b/src/components/button/ShareButton.tsx @@ -4,9 +4,15 @@ import ShareModal from "@/components/modal/ShareModal"; const ShareButton = () => { const [shareModalIsOpen, setShareModalIsOpen] = useState(false); + const handleClick = () => { + setShareModalIsOpen(true); + }; return ( <> - {shareModalIsOpen && ( diff --git a/src/components/header/MainHeader.tsx b/src/components/header/MainHeader.tsx index 56422df..8e734f0 100644 --- a/src/components/header/MainHeader.tsx +++ b/src/components/header/MainHeader.tsx @@ -1,10 +1,12 @@ import { Link, useNavigate } from "react-router-dom"; import LoginButton from "@/components/button/LoginButton"; +import trackClickEvent from "@/utils/trackClickEvent"; const MainHeader = ({ isLoggedIn }: { isLoggedIn: boolean }) => { const navigate = useNavigate(); const handleNavigate = () => { + trackClickEvent("홈", "내정보"); navigate("/my-info"); }; diff --git a/src/components/tips/TipsMenu.tsx b/src/components/tips/TipsMenu.tsx index bfd5e8b..5a3bc25 100644 --- a/src/components/tips/TipsMenu.tsx +++ b/src/components/tips/TipsMenu.tsx @@ -1,3 +1,4 @@ +import trackClickEvent from "@/utils/trackClickEvent"; import { Link } from "react-router-dom"; const TipsMenu = ({ @@ -6,22 +7,26 @@ const TipsMenu = ({ mode: "topic" | "conversation" | "temporature"; }) => { let text = ""; + let tagElement = ""; let imageUrl = ""; let href = ""; switch (mode) { case "topic": text = "대화 주제 추천"; + tagElement = "대화 주제 추천"; imageUrl = "/icon/starbubble.svg"; href = "/chat-recommend"; break; case "conversation": text = "대화 꿀팁"; + tagElement = "대화 꿀팁"; imageUrl = "/icon/lightbulb.svg"; href = "/chat-tips"; break; case "temporature": text = "현재 대화의 온도 측정하기"; + tagElement = "대화의 온도"; imageUrl = "/icon/thermometer.svg"; href = "/chat-temporature"; break; @@ -29,8 +34,12 @@ const TipsMenu = ({ return; } + const handleClick = () => { + trackClickEvent("채팅방", tagElement); + }; + return ( - +
{text}

{text}

diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index ce1f32d..ee87ba3 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -1,12 +1,12 @@ import { useEffect, useRef, useState, ChangeEvent, KeyboardEvent } from "react"; +import { useLocation } from "react-router-dom"; import IntroGuide from "@/components/IntroGuide"; import Header from "@/components/header/Header"; import ChatMessage from "@/components/ChatMessage"; import ChatActionBar from "@/components/ChatActionBar"; -import pickMbtiImage from "@/utils/pickMbtiImage"; -import instance from "@/api/axios"; -import { useLocation } from "react-router-dom"; import TipsMenuContainer from "@/components/tips/TipsMenuContainer"; +import pickMbtiImage from "@/utils/pickMbtiImage"; +import { authInstance } from "@/api/axios"; import { trackEvent } from "@/libs/analytics"; interface Message { @@ -18,81 +18,107 @@ interface ChatResponse { data: string; } +interface GetChatHistoryAPIResponse { + data: ChatHistoryResponse[]; +} + +interface ChatHistoryResponse { + messageContent: string; + virtualFriendId: number | null; +} + const Chat = () => { const { state } = useLocation(); - const { mbti, mode, id = Date.now().toString() } = state; + const { mbti, mode, id = Date.now().toString(), name } = state; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isOpen, setIsOpen] = useState(false); - const bottomRef = useRef(null); + const bottomRef = useRef(null); - const chatTitle = `${mbti}와 대화`; - const assistantInfo = mbti; - const assistantImgUrl = pickMbtiImage(assistantInfo); + const chatTitle = mode === "fastFriend" ? `${mbti}와 대화` : `${name}과 대화`; + const assistantImgUrl = pickMbtiImage(mbti); const storageKey = `chatMessages_${id}`; useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, isOpen]); + const fetchMessages = async () => { + if (mode === "virtualFriend") { + try { + const virtualFriendChatHistory = + await authInstance.get( + `/api/message/${id}` + ); + const chatHistory = virtualFriendChatHistory.data.data; + + const fetchedMessages: Message[] = chatHistory.map((msg) => ({ + role: msg.virtualFriendId ? "assistant" : "user", + content: msg.messageContent + })); + + setMessages(fetchedMessages); + } catch (error) { + console.error("채팅 불러오기 실패", error); + } + } else { + const storedMessage = sessionStorage.getItem(storageKey); + if (storedMessage) { + setMessages(JSON.parse(storedMessage)); + } + } + }; + + fetchMessages(); + }, [mode, id, storageKey]); useEffect(() => { - const stored = sessionStorage.getItem(storageKey); - if (stored) setMessages(JSON.parse(stored)); - }, [storageKey]); + if (mode !== "virtualFriend") { + sessionStorage.setItem(storageKey, JSON.stringify(messages)); + } + }, [messages, mode, storageKey]); useEffect(() => { - sessionStorage.setItem(storageKey, JSON.stringify(messages)); - }, [messages, storageKey]); + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isOpen]); const handleToggleTips = () => { - const nextAction = !isOpen; - + const nextState = !isOpen; trackEvent("Click", { page: "채팅방", - element: nextAction ? "콘텐츠 열기" : "콘텐츠 닫기" + element: nextState ? "콘텐츠 열기" : "콘텐츠 닫기" }); - - setIsOpen(nextAction); + setIsOpen(nextState); }; const handleSend = async (messageToSend: string) => { if (!messageToSend.trim()) return; - const newMessages: Message[] = [ + const updatedMessages: Message[] = [ ...messages, { role: "user", content: messageToSend } ]; - setMessages(newMessages); + setMessages(updatedMessages); setInput(""); try { - let url = ""; - let payload = {}; - - if (mode === "fastFriend") { - url = "/api/fast-friend/message"; - payload = { fastFriendId: id, content: messageToSend }; - } else { - url = "/api/message"; - payload = { - conversationId: id, - messageContent: messageToSend - }; - } + const url = + mode === "fastFriend" ? "/api/fast-friend/message" : "/api/message"; + const payload = + mode === "fastFriend" + ? { fastFriendId: id, content: messageToSend } + : { conversationId: id, messageContent: messageToSend }; - const response = await instance.post(url, payload); + const { data } = await authInstance.post(url, payload); setMessages([ - ...newMessages, + ...updatedMessages, { role: "assistant", - content: response.data.data || "응답이 없어요" + content: data.data || "응답이 없어요" } ]); - } catch (e) { + } catch (error) { setMessages([ - ...newMessages, + ...updatedMessages, { role: "assistant", content: "오류가 발생했어요. 다시 시도해 주세요." } ]); } @@ -104,7 +130,7 @@ const Chat = () => { const handleKeyup = (e: KeyboardEvent) => { if (e.key === "Enter") { - handleSend(e.currentTarget.value); + handleSend(input); } }; @@ -115,12 +141,10 @@ const Chat = () => {
{/* 메시지 리스트 */} - {messages.map((msg, index) => ( + {messages.map((msg, idx) => (
{/* 캐릭터 아이콘 */} {msg.role === "assistant" && ( diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index a324fbe..3862dfb 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -34,7 +34,7 @@ const Content = () => { return (
-
+
{/* 상단 배너 */} diff --git a/src/pages/MbtiTestIntro.tsx b/src/pages/MbtiTestIntro.tsx index d32567a..b167903 100644 --- a/src/pages/MbtiTestIntro.tsx +++ b/src/pages/MbtiTestIntro.tsx @@ -2,6 +2,8 @@ import { ChangeEvent, FormEvent, useState } from "react"; import { useNavigate } from "react-router-dom"; import Header from "@/components/header/Header"; import useLayoutSize from "@/hooks/useLayoutSize"; +import trackClickEvent from "@/utils/trackClickEvent"; + const MbtiTestIntro = () => { const [name, setName] = useState(""); const navigate = useNavigate(); @@ -59,6 +61,7 @@ const MbtiTestIntro = () => { diff --git a/src/pages/MbtiTestQuestions.tsx b/src/pages/MbtiTestQuestions.tsx index ab0655c..41256aa 100644 --- a/src/pages/MbtiTestQuestions.tsx +++ b/src/pages/MbtiTestQuestions.tsx @@ -1,7 +1,10 @@ +import { useEffect } from "react"; +import { trackPageView } from "@/libs/analytics"; import { TEST_QNA } from "@/constants/TEST_QNA"; import MbtiAnswerButtons from "@/components/button/MbtiAnswerButtons"; import useMbtiTestState from "@/store/useMbtiTestState"; import Header from "@/components/header/Header"; +import Error from "@/pages/Error"; interface content { number: number; @@ -15,31 +18,35 @@ interface content { const MbtiTestQuestions = () => { const { currentPage } = useMbtiTestState(); + useEffect(() => { + trackPageView(`바이럴 콘텐츠 (질문 ${currentPage})`, ""); + }, [currentPage]); + if (currentPage) { const content: content = TEST_QNA[Number(currentPage) - 1]; return ( -
-
-
- - {content.number}/12 - -

- {content.question} -

- mbti 테스트 과정 이미지 -
- -
+
+
+
+ + {content.number}/12 + +

+ {content.question} +

+ mbti 테스트 과정 이미지 +
+ +
); - } else return
404 error
; + } else return ; }; export default MbtiTestQuestions; diff --git a/src/pages/MbtiTestResult.tsx b/src/pages/MbtiTestResult.tsx index 3d2190e..f8a00e1 100644 --- a/src/pages/MbtiTestResult.tsx +++ b/src/pages/MbtiTestResult.tsx @@ -1,4 +1,3 @@ -import { MouseEvent } from "react"; import { MBTI_RESULT_INFO } from "@/constants/MBTI_RESULT_INFO"; import ShareButton from "@/components/button/ShareButton"; import RestartButton from "@/components/button/RestartButton"; diff --git a/src/pages/MyInfo.tsx b/src/pages/MyInfo.tsx index 03a2037..68772b0 100644 --- a/src/pages/MyInfo.tsx +++ b/src/pages/MyInfo.tsx @@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom"; import TermsAndPrivacyModal from "@/components/modal/TermsAndPrivacyModal"; import { trackEvent } from "@/libs/analytics"; import { deleteUser } from "@/api/user"; +import Error from "@/pages/Error"; type ModalType = "logout" | "withdraw" | "terms" | "privacy" | null; @@ -26,7 +27,7 @@ const alertConfig = { const MyInfo = () => { const [modalType, setModalType] = useState(null); - const { logout } = useAuthStore(); + const { logout, isLoggedIn } = useAuthStore(); const navigate = useNavigate(); const handleCancel = () => { @@ -60,12 +61,16 @@ const MyInfo = () => { }; const menuItems = [ - { label: "이용약관", onClick: () => setModalType("terms") }, //TODO: 이용약관 팝업 구현 시 추가 필요 - { label: "개인정보처리방침", onClick: () => setModalType("privacy") }, //TODO: 개인정보처리방침 팝업 구현 시 추가 필요 + { label: "이용약관", onClick: () => setModalType("terms") }, + { label: "개인정보처리방침", onClick: () => setModalType("privacy") }, { label: "로그아웃", onClick: () => setModalType("logout") }, { label: "회원탈퇴", onClick: () => setModalType("withdraw") } ]; + if (!isLoggedIn) { + return ; + } + return (
diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index 87be9b8..b1db917 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -56,6 +56,9 @@ const SelectInfo = () => { ? testResultMBTI : undefined; + const confirmButtonText = + type === "fastFriend" ? "대화 시작하기" : "친구 저장하기"; + const [selectedMBTI, setSelectedMBTI] = useState<{ [key: string]: string | null; }>({ @@ -152,7 +155,7 @@ const SelectInfo = () => { setTimeout(() => setToastMessage(null), 3000); }; - const handleStartChat = async () => { + const handleConfirmButton = async () => { const isMBTIComplete = Object.values(selectedMBTI).every( (val) => val !== null ); @@ -201,15 +204,9 @@ const SelectInfo = () => { if (type === "virtualFriend" && isVirtualFriendResponse(responseData)) { trackEvent("Click", { page: "친구 저장", - element: "대화 시작하기" - }); - navigate("/chat", { - state: { - mbti, - mode: type, - id: responseData.conversationId - } + element: "친구 저장하기" }); + navigate("/"); } else if (type === "fastFriend" && typeof responseData === "number") { trackEvent("Click", { page: "빠른 대화 설정", @@ -230,7 +227,7 @@ const SelectInfo = () => { return (
-
+
{/* MBTI 선택 */} @@ -371,9 +368,9 @@ const SelectInfo = () => { {/* 대화 시작 버튼 */}
diff --git a/src/utils/trackClickEvent.ts b/src/utils/trackClickEvent.ts new file mode 100644 index 0000000..236d67b --- /dev/null +++ b/src/utils/trackClickEvent.ts @@ -0,0 +1,10 @@ +import { trackEvent } from "@/libs/analytics"; + +const trackClickEvent = (page: string, element: string) => { + trackEvent("Click", { + page: page, + element: element + }); +}; + +export default trackClickEvent;